Coverage for mcpgateway / admin.py: 99%
6317 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-18 12:49 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-18 12:49 +0000
1# -*- coding: utf-8 -*-
2"""Location: ./mcpgateway/admin.py
3Copyright 2025
4SPDX-License-Identifier: Apache-2.0
5Authors: Mihai Criveti
7Admin UI Routes for MCP Gateway.
8This module contains all the administrative UI endpoints for the MCP Gateway.
9It provides a comprehensive interface for managing servers, tools, resources,
10prompts, gateways, and roots through RESTful API endpoints. The module handles
11all aspects of CRUD operations for these entities, including creation,
12reading, updating, deletion, and status toggling.
14All endpoints in this module require authentication, which is enforced via
15the require_auth or require_basic_auth dependency. The module integrates with
16various services to perform the actual business logic operations on the
17underlying data.
18"""
20# Standard
21import asyncio
22import binascii
23from collections import defaultdict
24import csv
25from datetime import datetime, timedelta, timezone
26from functools import wraps
27import html
28import io
29import logging
30import math
31import os
32from pathlib import Path
33import re
34import tempfile
35import time
36from typing import Any
37from typing import cast as typing_cast
38from typing import Dict, List, Optional, Union
39import urllib.parse
40import uuid
42# Third-Party
43from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response
44from fastapi.encoders import jsonable_encoder
45from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
46from fastapi.security import HTTPAuthorizationCredentials
47import httpx
48import orjson
49from pydantic import SecretStr, ValidationError
50from pydantic_core import ValidationError as CoreValidationError
51from sqlalchemy import and_, bindparam, case, cast, desc, false, func, or_, select, String, text
52from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
53from sqlalchemy.orm import joinedload, selectinload, Session
54from sqlalchemy.sql.functions import coalesce
55from starlette.background import BackgroundTask
56from starlette.datastructures import UploadFile as StarletteUploadFile
58# First-Party
59from mcpgateway import __version__
60from mcpgateway import version as version_module
62# Authentication and password-related imports
63from mcpgateway.auth import get_current_user, get_user_team_roles
64from mcpgateway.cache.a2a_stats_cache import a2a_stats_cache
65from mcpgateway.cache.global_config_cache import global_config_cache
66from mcpgateway.common.models import LogLevel
67from mcpgateway.common.validators import SecurityValidator
68from mcpgateway.config import settings, UI_HIDABLE_HEADER_ITEMS, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES
69from mcpgateway.db import A2AAgent as DbA2AAgent
70from mcpgateway.db import EmailApiToken, EmailTeam, extract_json_field
71from mcpgateway.db import Gateway as DbGateway
72from mcpgateway.db import get_db, GlobalConfig, ObservabilitySavedQuery, ObservabilitySpan, ObservabilityTrace
73from mcpgateway.db import Prompt as DbPrompt
74from mcpgateway.db import Resource as DbResource
75from mcpgateway.db import Server as DbServer
76from mcpgateway.db import Tool as DbTool
77from mcpgateway.db import utc_now
78from mcpgateway.middleware.rbac import get_current_user_with_permissions, require_any_permission, require_permission
79from mcpgateway.routers.email_auth import create_access_token
80from mcpgateway.schemas import (
81 A2AAgentCreate,
82 A2AAgentRead,
83 A2AAgentUpdate,
84 CatalogBulkRegisterRequest,
85 CatalogBulkRegisterResponse,
86 CatalogListRequest,
87 CatalogListResponse,
88 CatalogServerRegisterRequest,
89 CatalogServerRegisterResponse,
90 CatalogServerStatusResponse,
91 GatewayCreate,
92 GatewayRead,
93 GatewayTestRequest,
94 GatewayTestResponse,
95 GatewayUpdate,
96 GlobalConfigRead,
97 GlobalConfigUpdate,
98 PaginatedResponse,
99 PaginationMeta,
100 PluginDetail,
101 PluginListResponse,
102 PluginStatsResponse,
103 PromptCreate,
104 PromptMetrics,
105 PromptRead,
106 PromptUpdate,
107 ResourceCreate,
108 ResourceMetrics,
109 ResourceUpdate,
110 ServerCreate,
111 ServerMetrics,
112 ServerRead,
113 ServerUpdate,
114 ToolCreate,
115 ToolMetrics,
116 ToolRead,
117 ToolUpdate,
118)
119from mcpgateway.services.a2a_service import A2AAgentError, A2AAgentNameConflictError, A2AAgentNotFoundError, A2AAgentService
120from mcpgateway.services.argon2_service import Argon2PasswordService
121from mcpgateway.services.audit_trail_service import get_audit_trail_service
122from mcpgateway.services.catalog_service import catalog_service
123from mcpgateway.services.email_auth_service import AuthenticationError, EmailAuthService, PasswordValidationError
124from mcpgateway.services.encryption_service import get_encryption_service
125from mcpgateway.services.export_service import ExportError, ExportService
126from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayDuplicateConflictError, GatewayNameConflictError, GatewayNotFoundError, GatewayService
127from mcpgateway.services.import_service import ConflictStrategy
128from mcpgateway.services.import_service import ImportError as ImportServiceError
129from mcpgateway.services.import_service import ImportService, ImportValidationError
130from mcpgateway.services.logging_service import LoggingService
131from mcpgateway.services.mcp_session_pool import get_mcp_session_pool
132from mcpgateway.services.oauth_manager import OAuthManager
133from mcpgateway.services.performance_service import get_performance_service
134from mcpgateway.services.permission_service import PermissionService
135from mcpgateway.services.plugin_service import get_plugin_service
136from mcpgateway.services.prompt_service import PromptNameConflictError, PromptNotFoundError, PromptService
137from mcpgateway.services.resource_service import ResourceNotFoundError, ResourceService, ResourceURIConflictError
138from mcpgateway.services.root_service import RootService, RootServiceError, RootServiceNotFoundError
139from mcpgateway.services.server_service import ServerError, ServerLockConflictError, ServerNameConflictError, ServerNotFoundError, ServerService
140from mcpgateway.services.structured_logger import get_structured_logger
141from mcpgateway.services.tag_service import TagService
142from mcpgateway.services.team_management_service import TeamManagementService
143from mcpgateway.services.token_catalog_service import TokenCatalogService
144from mcpgateway.services.tool_service import ToolError, ToolLockConflictError, ToolNameConflictError, ToolNotFoundError, ToolService
145from mcpgateway.utils.create_jwt_token import create_jwt_token, get_jwt_token
146from mcpgateway.utils.error_formatter import ErrorFormatter
147from mcpgateway.utils.metadata_capture import MetadataCapture
148from mcpgateway.utils.orjson_response import ORJSONResponse
149from mcpgateway.utils.pagination import paginate_query
150from mcpgateway.utils.passthrough_headers import PassthroughHeadersError
151from mcpgateway.utils.retry_manager import ResilientHttpClient
152from mcpgateway.utils.security_cookies import clear_auth_cookie, CookieTooLargeError, set_auth_cookie
153from mcpgateway.utils.services_auth import decode_auth
154from mcpgateway.utils.sqlalchemy_modifier import json_contains_tag_expr
155from mcpgateway.utils.validate_signature import sign_data
156from mcpgateway.utils.verify_credentials import verify_jwt_token_cached
158# Conditional imports for gRPC support (only if grpcio is installed)
159try:
160 # First-Party
161 from mcpgateway.schemas import GrpcServiceCreate, GrpcServiceRead, GrpcServiceUpdate
162 from mcpgateway.services.grpc_service import GrpcService, GrpcServiceError, GrpcServiceNameConflictError, GrpcServiceNotFoundError
164 GRPC_AVAILABLE = True
165except ImportError:
166 GRPC_AVAILABLE = False
167 # Define placeholder types to avoid NameError
168 GrpcServiceCreate = None # type: ignore
169 GrpcServiceRead = None # type: ignore
170 GrpcServiceUpdate = None # type: ignore
171 GrpcService = None # type: ignore
173 # Define placeholder exception classes that maintain the hierarchy
174 class GrpcServiceError(Exception): # type: ignore
175 """Placeholder for GrpcServiceError when grpcio is not installed."""
177 class GrpcServiceNotFoundError(GrpcServiceError): # type: ignore
178 """Placeholder for GrpcServiceNotFoundError when grpcio is not installed."""
180 class GrpcServiceNameConflictError(GrpcServiceError): # type: ignore
181 """Placeholder for GrpcServiceNameConflictError when grpcio is not installed."""
184# Import the shared logging service from main
185# This will be set by main.py when it imports admin_router
186logging_service: Optional[LoggingService] = None
187LOGGER: logging.Logger = logging.getLogger("mcpgateway.admin")
189UI_SECTION_TO_TABS: Dict[str, tuple[str, ...]] = {
190 "overview": ("overview",),
191 "servers": ("catalog",),
192 "gateways": ("gateways",),
193 "tools": ("tools", "tool-ops"),
194 "prompts": ("prompts",),
195 "resources": ("resources",),
196 "roots": ("roots",),
197 "mcp-registry": ("mcp-registry",),
198 "metrics": ("metrics",),
199 "plugins": ("plugins",),
200 "export-import": ("export-import",),
201 "logs": ("logs",),
202 "version-info": ("version-info",),
203 "maintenance": ("maintenance",),
204 "teams": ("teams",),
205 "users": ("users",),
206 "agents": ("a2a-agents", "grpc-services"),
207 "tokens": ("tokens",),
208 "settings": ("llm-settings",),
209}
210UI_EMBEDDED_DEFAULT_HIDDEN_HEADER_ITEMS: frozenset[str] = frozenset({"logout", "team_selector"})
211UI_HIDE_SECTIONS_COOKIE_NAME = "mcpgateway_ui_hide_sections"
212UI_HIDE_SECTIONS_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
215def _normalize_ui_hide_values(raw: Any, valid_values: frozenset[str], aliases: Optional[Dict[str, str]] = None) -> set[str]:
216 """Normalize UI hide values from CSV/list input into a validated set.
218 Args:
219 raw: Source value (CSV string, iterable, or ``None``).
220 valid_values: Allowed normalized values.
221 aliases: Optional alias mapping to canonical values.
223 Returns:
224 set[str]: Lowercase validated values with aliases resolved.
225 """
226 if raw is None:
227 return set()
229 tokens: list[str] = []
230 if isinstance(raw, str):
231 tokens = [item.strip() for item in raw.split(",")]
232 elif isinstance(raw, (list, tuple, set)):
233 tokens = [str(item).strip() for item in raw]
234 else:
235 return set()
237 normalized: set[str] = set()
238 for token in tokens:
239 if not token:
240 continue
241 item = token.lower()
242 if aliases:
243 item = aliases.get(item, item)
244 if item in valid_values:
245 normalized.add(item)
246 return normalized
249def get_ui_visibility_config(request: Request) -> Dict[str, Any]:
250 """Build final UI visibility settings for the current admin request.
252 Args:
253 request: Incoming FastAPI request.
255 Returns:
256 Dict[str, Any]: Hidden sections/header items/tabs plus cookie update intent.
257 """
258 hidden_sections = _normalize_ui_hide_values(getattr(settings, "mcpgateway_ui_hide_sections", []), UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES)
259 hidden_header_items = _normalize_ui_hide_values(getattr(settings, "mcpgateway_ui_hide_header_items", []), UI_HIDABLE_HEADER_ITEMS)
261 if bool(getattr(settings, "mcpgateway_ui_embedded", False)):
262 hidden_header_items.update(UI_EMBEDDED_DEFAULT_HIDDEN_HEADER_ITEMS)
264 query_ui_hide_raw = request.query_params.get("ui_hide")
265 query_ui_hide_values = _normalize_ui_hide_values(query_ui_hide_raw, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES) if query_ui_hide_raw is not None else set()
267 if query_ui_hide_raw is not None:
268 # Query override is additive and also becomes the persisted session value.
269 hidden_sections.update(query_ui_hide_values)
270 else:
271 cookie_ui_hide_raw = request.cookies.get(UI_HIDE_SECTIONS_COOKIE_NAME)
272 hidden_sections.update(_normalize_ui_hide_values(cookie_ui_hide_raw, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES))
274 hidden_tabs: set[str] = set()
275 for section in hidden_sections:
276 hidden_tabs.update(UI_SECTION_TO_TABS.get(section, ()))
278 cookie_action: Optional[str] = None
279 cookie_value: Optional[str] = None
280 if query_ui_hide_raw is not None:
281 # Empty query clears persisted overrides.
282 if query_ui_hide_values:
283 cookie_action = "set"
284 cookie_value = ",".join(sorted(query_ui_hide_values))
285 else:
286 cookie_action = "delete"
288 return {
289 "hidden_sections": sorted(hidden_sections),
290 "hidden_header_items": sorted(hidden_header_items),
291 "hidden_tabs": sorted(hidden_tabs),
292 "cookie_action": cookie_action,
293 "cookie_value": cookie_value,
294 }
297def set_logging_service(service: LoggingService):
298 """Set the logging service instance to use.
300 This should be called by main.py to share the same logging service.
302 Args:
303 service: The LoggingService instance to use
305 Examples:
306 >>> from mcpgateway.services.logging_service import LoggingService
307 >>> from mcpgateway import admin
308 >>> logging_svc = LoggingService()
309 >>> admin.set_logging_service(logging_svc)
310 >>> admin.logging_service is not None
311 True
312 >>> admin.LOGGER is not None
313 True
315 Test with different service instance:
316 >>> new_svc = LoggingService()
317 >>> admin.set_logging_service(new_svc)
318 >>> admin.logging_service == new_svc
319 True
320 >>> admin.LOGGER.name
321 'mcpgateway.admin'
323 Test that global variables are properly set:
324 >>> admin.set_logging_service(logging_svc)
325 >>> hasattr(admin, 'logging_service')
326 True
327 >>> hasattr(admin, 'LOGGER')
328 True
329 """
330 global logging_service, LOGGER # pylint: disable=global-statement
331 logging_service = service
332 LOGGER = logging_service.get_logger("mcpgateway.admin")
335# Fallback for testing - create a temporary instance if not set
336if logging_service is None:
337 logging_service = LoggingService()
338 LOGGER = logging_service.get_logger("mcpgateway.admin")
341# Initialize services
342server_service: ServerService = ServerService()
343tool_service: ToolService = ToolService()
344prompt_service: PromptService = PromptService()
345gateway_service: GatewayService = GatewayService()
346resource_service: ResourceService = ResourceService()
347root_service: RootService = RootService()
348export_service: ExportService = ExportService()
349import_service: ImportService = ImportService()
350# Initialize A2A service only if A2A features are enabled
351a2a_service: Optional[A2AAgentService] = A2AAgentService() if settings.mcpgateway_a2a_enabled else None
352# Initialize gRPC service only if gRPC features are enabled AND grpcio is installed
353grpc_service_mgr: Optional[Any] = GrpcService() if (settings.mcpgateway_grpc_enabled and GRPC_AVAILABLE and GrpcService is not None) else None
355# Set up basic authentication
357# Rate limiting storage
358rate_limit_storage = defaultdict(list)
361def _normalize_team_id(team_id: Optional[str]) -> Optional[str]:
362 """Validate and normalize team IDs for UI endpoints.
364 Args:
365 team_id: Raw team ID from request params.
367 Returns:
368 Normalized team ID string or None.
370 Raises:
371 ValueError: If the team ID is not a valid UUID.
372 """
373 if not team_id:
374 return None
375 try:
376 return uuid.UUID(str(team_id)).hex
377 except (ValueError, AttributeError, TypeError) as exc:
378 raise ValueError("Invalid team ID") from exc
381def _validated_team_id_param(team_id: Optional[str] = Query(None, description="Filter by team ID")) -> Optional[str]:
382 """Normalize team ID query params and raise on invalid UUIDs.
384 Args:
385 team_id: Raw team ID from query params.
387 Returns:
388 Normalized team ID string or None.
390 Raises:
391 HTTPException: If the team ID is not a valid UUID.
392 """
393 try:
394 return _normalize_team_id(team_id)
395 except ValueError as exc:
396 raise HTTPException(status_code=400, detail="Invalid team ID") from exc
399def get_client_ip(request: Request) -> str:
400 """Extract client IP address from request.
402 Args:
403 request: FastAPI request object
405 Returns:
406 str: Client IP address
408 Examples:
409 >>> from unittest.mock import MagicMock
410 >>>
411 >>> # Test with X-Forwarded-For header
412 >>> mock_request = MagicMock()
413 >>> mock_request.headers = {"X-Forwarded-For": "192.168.1.1, 10.0.0.1"}
414 >>> get_client_ip(mock_request)
415 '192.168.1.1'
416 >>>
417 >>> # Test with X-Real-IP header
418 >>> mock_request.headers = {"X-Real-IP": "10.0.0.5"}
419 >>> get_client_ip(mock_request)
420 '10.0.0.5'
421 >>>
422 >>> # Test with direct client IP
423 >>> mock_request.headers = {}
424 >>> mock_request.client.host = "127.0.0.1"
425 >>> get_client_ip(mock_request)
426 '127.0.0.1'
427 >>>
428 >>> # Test with no client info
429 >>> mock_request.client = None
430 >>> get_client_ip(mock_request)
431 'unknown'
432 """
433 # Check for X-Forwarded-For header (proxy/load balancer)
434 forwarded_for = request.headers.get("X-Forwarded-For")
435 if forwarded_for:
436 return forwarded_for.split(",")[0].strip()
438 # Check for X-Real-IP header
439 real_ip = request.headers.get("X-Real-IP")
440 if real_ip:
441 return real_ip
443 # Fall back to direct client IP
444 return request.client.host if request.client else "unknown"
447def get_user_agent(request: Request) -> str:
448 """Extract user agent from request.
450 Args:
451 request: FastAPI request object
453 Returns:
454 str: User agent string
456 Examples:
457 >>> from unittest.mock import MagicMock
458 >>>
459 >>> # Test with User-Agent header
460 >>> mock_request = MagicMock()
461 >>> mock_request.headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0)"}
462 >>> get_user_agent(mock_request)
463 'Mozilla/5.0 (Windows NT 10.0)'
464 >>>
465 >>> # Test without User-Agent header
466 >>> mock_request.headers = {}
467 >>> get_user_agent(mock_request)
468 'unknown'
469 """
470 return request.headers.get("User-Agent", "unknown")
473def rate_limit(requests_per_minute: Optional[int] = None):
474 """Apply rate limiting to admin endpoints.
476 Args:
477 requests_per_minute: Maximum requests per minute (uses config default if None)
479 Returns:
480 Decorator function that enforces rate limiting
482 Examples:
483 Test basic decorator creation:
484 >>> from mcpgateway import admin
485 >>> decorator = admin.rate_limit(10)
486 >>> callable(decorator)
487 True
489 Test with None parameter (uses default):
490 >>> default_decorator = admin.rate_limit(None)
491 >>> callable(default_decorator)
492 True
494 Test with specific limit:
495 >>> limited_decorator = admin.rate_limit(5)
496 >>> callable(limited_decorator)
497 True
499 Test decorator returns wrapper:
500 >>> async def dummy_func():
501 ... return "success"
502 >>> decorated_func = decorator(dummy_func)
503 >>> callable(decorated_func)
504 True
506 Test rate limit storage structure:
507 >>> isinstance(admin.rate_limit_storage, dict)
508 True
509 >>> from collections import defaultdict
510 >>> isinstance(admin.rate_limit_storage, defaultdict)
511 True
513 Test decorator with zero limit:
514 >>> zero_limit_decorator = admin.rate_limit(0)
515 >>> callable(zero_limit_decorator)
516 True
518 Test decorator with high limit:
519 >>> high_limit_decorator = admin.rate_limit(1000)
520 >>> callable(high_limit_decorator)
521 True
522 """
524 def decorator(func_to_wrap):
525 """Decorator that wraps the function with rate limiting logic.
527 Args:
528 func_to_wrap: The function to be wrapped with rate limiting
530 Returns:
531 The wrapped function with rate limiting applied
532 """
534 @wraps(func_to_wrap)
535 async def wrapper(*args, request: Optional[Request] = None, **kwargs):
536 """Execute the wrapped function with rate limiting enforcement.
538 Args:
539 *args: Positional arguments to pass to the wrapped function
540 request: FastAPI Request object for extracting client IP
541 **kwargs: Keyword arguments to pass to the wrapped function
543 Returns:
544 The result of the wrapped function call
546 Raises:
547 HTTPException: When rate limit is exceeded (429 status)
548 """
549 # use configured limit if none provided
550 limit = requests_per_minute or settings.validation_max_requests_per_minute
552 # request can be None in some edge cases (e.g., tests)
553 client_ip = request.client.host if request and request.client else "unknown"
554 current_time = time.time()
555 minute_ago = current_time - 60
557 # prune old timestamps
558 rate_limit_storage[client_ip] = [ts for ts in rate_limit_storage[client_ip] if ts > minute_ago]
560 # enforce
561 if len(rate_limit_storage[client_ip]) >= limit:
562 LOGGER.warning(f"Rate limit exceeded for IP {client_ip} on endpoint {func_to_wrap.__name__}")
563 raise HTTPException(
564 status_code=429,
565 detail=f"Rate limit exceeded. Maximum {limit} requests per minute.",
566 )
567 rate_limit_storage[client_ip].append(current_time)
568 # IMPORTANT: forward request to the real endpoint
569 return await func_to_wrap(*args, request=request, **kwargs)
571 return wrapper
573 return decorator
576def get_user_email(user: Union[str, dict, object] = None) -> str:
577 """Return the user email from a JWT payload, user object, or string.
579 Args:
580 user (Union[str, dict, object], optional): User object from JWT token
581 (from get_current_user_with_permissions). Can be:
582 - dict: representing JWT payload
583 - object: with an `email` attribute
584 - str: an email string
585 - None: will return "unknown"
586 Defaults to None.
588 Returns:
589 str: User email address, or "unknown" if no email can be determined.
590 - If `user` is a dict, returns `sub` if present, else `email`, else "unknown".
591 - If `user` has an `email` attribute, returns that.
592 - If `user` is a string, returns it.
593 - If `user` is None, returns "unknown".
594 - Otherwise, returns str(user).
596 Examples:
597 >>> get_user_email({'sub': 'alice@example.com'})
598 'alice@example.com'
599 >>> get_user_email({'email': 'bob@company.com'})
600 'bob@company.com'
601 >>> get_user_email({'sub': 'charlie@primary.com', 'email': 'charlie@secondary.com'})
602 'charlie@primary.com'
603 >>> get_user_email({'username': 'dave'})
604 'unknown'
605 >>> class MockUser:
606 ... def __init__(self, email):
607 ... self.email = email
608 >>> get_user_email(MockUser('eve@test.com'))
609 'eve@test.com'
610 >>> get_user_email(None)
611 'unknown'
612 >>> get_user_email('grace@example.org')
613 'grace@example.org'
614 >>> get_user_email({})
615 'unknown'
616 >>> get_user_email(12345)
617 '12345'
618 """
619 if isinstance(user, dict):
620 return user.get("sub") or user.get("email") or "unknown"
622 if hasattr(user, "email"):
623 return user.email
625 if user is None:
626 return "unknown"
628 return str(user)
631def _get_user_team_roles(db: Session, user_email: str) -> Dict[str, str]:
632 """Return a {team_id: role} mapping for a user's active memberships.
634 Args:
635 db: The SQLAlchemy database session.
636 user_email: Email address of the user to query memberships for.
638 Returns:
639 Dict mapping team_id to the user's role in that team.
640 """
641 return get_user_team_roles(db, user_email)
644def _adjust_pagination_for_conversion_failures(pagination: "PaginationMeta", failed_count: int) -> None:
645 """Adjust pagination metadata to account for DB-to-Pydantic conversion failures.
647 When items on the current page fail to convert, the "Showing X of Y" display
648 would otherwise count items that aren't actually displayed. This adjusts
649 total_items and recomputes derived fields (total_pages, has_next, has_prev).
651 Args:
652 pagination: The PaginationMeta object to adjust (modified in-place).
653 failed_count: Number of items that failed conversion on the current page.
654 """
655 if failed_count > 0:
656 pagination.total_items = max(0, pagination.total_items - failed_count)
657 pagination.total_pages = math.ceil(pagination.total_items / pagination.per_page) if pagination.total_items > 0 else 0
658 # Do NOT clamp pagination.page — data was already fetched for this page,
659 # so the page number must match the displayed data.
660 pagination.has_next = pagination.page < pagination.total_pages
661 pagination.has_prev = pagination.page > 1
664def _get_span_entity_performance(
665 db: Session,
666 cutoff_time: datetime,
667 cutoff_time_naive: datetime,
668 span_names: List[str],
669 json_key: str,
670 result_key: str,
671 limit: int = 20,
672) -> List[dict]:
673 """Shared helper to compute performance metrics for spans grouped by a JSON attribute.
675 Args:
676 db: Database session.
677 cutoff_time: Timezone-aware datetime for filtering spans.
678 cutoff_time_naive: Naive datetime for SQLite compatibility.
679 span_names: List of span names to filter (e.g., ["tool.invoke"]).
680 json_key: JSON attribute key to group by (e.g., "tool.name").
681 result_key: Key name for the entity in returned dicts (e.g., "tool_name").
682 limit: Maximum number of results to return (default: 20).
684 Returns:
685 List[dict]: List of dicts with entity key and performance metrics (count, avg, min, max, percentiles).
687 Raises:
688 ValueError: If `json_key` is not a valid identifier (only letters, digits, underscore, dot or hyphen),
689 this function will raise a ValueError to prevent unsafe SQL interpolation when using
690 PostgreSQL native percentile queries.
692 Note:
693 Uses PostgreSQL `percentile_cont` when available and enabled via USE_POSTGRESDB_PERCENTILES config,
694 otherwise falls back to Python aggregation.
695 """
696 # Validate json_key to prevent SQL injection in both PostgreSQL and SQLite paths
697 if not isinstance(json_key, str) or not re.match(r"^[A-Za-z0-9_.-]+$", json_key):
698 raise ValueError("Invalid json_key for percentile query")
700 dialect_name = db.get_bind().dialect.name
702 # Use database-native percentiles only if enabled in config and using PostgreSQL
703 if dialect_name == "postgresql" and settings.use_postgresdb_percentiles:
704 # Safe: uses SQLAlchemy's bindparam for the IN-list
705 stats_sql = text(
706 """
707 SELECT
708 (attributes->> :json_key) AS entity,
709 COUNT(*) AS count,
710 AVG(duration_ms) AS avg_duration_ms,
711 MIN(duration_ms) AS min_duration_ms,
712 MAX(duration_ms) AS max_duration_ms,
713 percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) AS p50,
714 percentile_cont(0.90) WITHIN GROUP (ORDER BY duration_ms) AS p90,
715 percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95,
716 percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99
717 FROM observability_spans
718 WHERE name IN :names
719 AND start_time >= :cutoff_time
720 AND duration_ms IS NOT NULL
721 AND (attributes->> :json_key) IS NOT NULL
722 GROUP BY entity
723 ORDER BY avg_duration_ms DESC
724 LIMIT :limit
725 """
726 ).bindparams(bindparam("names", expanding=True))
728 results = db.execute(
729 stats_sql,
730 {"cutoff_time": cutoff_time, "limit": limit, "names": span_names, "json_key": json_key},
731 ).fetchall()
733 items: List[dict] = []
734 for row in results:
735 items.append(
736 {
737 result_key: row.entity,
738 "count": int(row.count) if row.count is not None else 0,
739 "avg_duration_ms": round(float(row.avg_duration_ms), 2) if row.avg_duration_ms is not None else 0,
740 "min_duration_ms": round(float(row.min_duration_ms), 2) if row.min_duration_ms is not None else 0,
741 "max_duration_ms": round(float(row.max_duration_ms), 2) if row.max_duration_ms is not None else 0,
742 "p50": round(float(row.p50), 2) if row.p50 is not None else 0,
743 "p90": round(float(row.p90), 2) if row.p90 is not None else 0,
744 "p95": round(float(row.p95), 2) if row.p95 is not None else 0,
745 "p99": round(float(row.p99), 2) if row.p99 is not None else 0,
746 }
747 )
749 return items
751 # Fallback: Python aggregation (SQLite or other DBs, or PostgreSQL with USE_POSTGRESDB_PERCENTILES=False)
752 # Pass dialect_name to extract_json_field to ensure correct SQL syntax for the actual database
753 # Use timezone-aware cutoff for PostgreSQL to avoid timezone drift, naive for SQLite
754 effective_cutoff = cutoff_time if dialect_name == "postgresql" else cutoff_time_naive
755 spans = (
756 db.query(
757 extract_json_field(ObservabilitySpan.attributes, f'$."{json_key}"', dialect_name=dialect_name).label("entity"),
758 ObservabilitySpan.duration_ms,
759 )
760 .filter(
761 ObservabilitySpan.name.in_(span_names),
762 ObservabilitySpan.start_time >= effective_cutoff,
763 ObservabilitySpan.duration_ms.isnot(None),
764 extract_json_field(ObservabilitySpan.attributes, f'$."{json_key}"', dialect_name=dialect_name).isnot(None),
765 )
766 .all()
767 )
769 durations_by_entity: Dict[str, List[float]] = defaultdict(list)
770 for span in spans:
771 durations_by_entity[span.entity].append(span.duration_ms)
773 def percentile(data: List[float], p: float) -> float:
774 """Calculate percentile using linear interpolation (matches PostgreSQL percentile_cont).
776 Args:
777 data: Sorted, non-empty list of numeric values.
778 p: Percentile to calculate (0.0 to 1.0).
780 Returns:
781 float: The interpolated percentile value.
782 """
783 n = len(data)
784 k = p * (n - 1)
785 f = int(k)
786 c = k - f
787 next_i = min(f + 1, n - 1)
788 return data[f] + c * (data[next_i] - data[f])
790 items: List[dict] = []
791 for entity, durations in durations_by_entity.items():
792 durations_sorted = sorted(durations)
793 n = len(durations_sorted)
794 items.append(
795 {
796 result_key: entity,
797 "count": n,
798 "avg_duration_ms": round(sum(durations) / n, 2),
799 "min_duration_ms": round(min(durations), 2),
800 "max_duration_ms": round(max(durations), 2),
801 "p50": round(percentile(durations_sorted, 0.50), 2),
802 "p90": round(percentile(durations_sorted, 0.90), 2),
803 "p95": round(percentile(durations_sorted, 0.95), 2),
804 "p99": round(percentile(durations_sorted, 0.99), 2),
805 }
806 )
808 items.sort(key=lambda x: x.get("avg_duration_ms", 0), reverse=True)
809 return items[:limit]
812def get_user_id(user: Union[str, dict[str, Any], object] = None) -> str:
813 """Return the user ID from a JWT payload, user object, or string.
815 Args:
816 user (Union[str, dict, object], optional): User object from JWT token
817 (from get_current_user_with_permissions). Can be:
818 - dict: representing JWT payload with 'id', 'user_id', or 'sub'
819 - object: with an `id` attribute
820 - str: a user ID string
821 - None: will return "unknown"
822 Defaults to None.
824 Returns:
825 str: User ID, or "unknown" if no ID can be determined.
826 - If `user` is a dict, returns `id` if present, else `user_id`, else `sub`, else email as fallback, else "unknown".
827 - If `user` has an `id` attribute, returns that.
828 - If `user` is a string, returns it.
829 - If `user` is None, returns "unknown".
830 - Otherwise, returns str(user).
832 Examples:
833 >>> get_user_id({'id': '123'})
834 '123'
835 >>> get_user_id({'user_id': '456'})
836 '456'
837 >>> get_user_id({'sub': 'alice@example.com'})
838 'alice@example.com'
839 >>> get_user_id({'email': 'bob@company.com'})
840 'bob@company.com'
841 >>> class MockUser:
842 ... def __init__(self, user_id):
843 ... self.id = user_id
844 >>> get_user_id(MockUser('789'))
845 '789'
846 >>> get_user_id(None)
847 'unknown'
848 >>> get_user_id('user-xyz')
849 'user-xyz'
850 >>> get_user_id({})
851 'unknown'
852 """
853 if isinstance(user, dict):
854 # Try multiple possible ID fields in order of preference.
855 # Email is the primary key in the model, so that's our mostly likely result.
856 return user.get("id") or user.get("user_id") or user.get("sub") or user.get("email") or "unknown"
858 return "unknown" if user is None else str(getattr(user, "id", user))
861def serialize_datetime(obj):
862 """Convert datetime objects to ISO format strings for JSON serialization.
864 Args:
865 obj: Object to serialize, potentially a datetime
867 Returns:
868 str: ISO format string if obj is datetime, otherwise returns obj unchanged
870 Examples:
871 Test with datetime object:
872 >>> from mcpgateway import admin
873 >>> from datetime import datetime, timezone
874 >>> dt = datetime(2025, 1, 15, 10, 30, 45, tzinfo=timezone.utc)
875 >>> admin.serialize_datetime(dt)
876 '2025-01-15T10:30:45+00:00'
878 Test with naive datetime:
879 >>> dt_naive = datetime(2025, 3, 20, 14, 15, 30)
880 >>> result = admin.serialize_datetime(dt_naive)
881 >>> '2025-03-20T14:15:30' in result
882 True
884 Test with datetime with microseconds:
885 >>> dt_micro = datetime(2025, 6, 10, 9, 25, 12, 500000)
886 >>> result = admin.serialize_datetime(dt_micro)
887 >>> '2025-06-10T09:25:12.500000' in result
888 True
890 Test with non-datetime objects (should return unchanged):
891 >>> admin.serialize_datetime("2025-01-15T10:30:45")
892 '2025-01-15T10:30:45'
893 >>> admin.serialize_datetime(12345)
894 12345
895 >>> admin.serialize_datetime(['a', 'list'])
896 ['a', 'list']
897 >>> admin.serialize_datetime({'key': 'value'})
898 {'key': 'value'}
899 >>> admin.serialize_datetime(None)
900 >>> admin.serialize_datetime(True)
901 True
903 Test with current datetime:
904 >>> import datetime as dt_module
905 >>> now = dt_module.datetime.now()
906 >>> result = admin.serialize_datetime(now)
907 >>> isinstance(result, str)
908 True
909 >>> 'T' in result # ISO format contains 'T' separator
910 True
912 Test edge case with datetime min/max:
913 >>> dt_min = datetime.min
914 >>> result = admin.serialize_datetime(dt_min)
915 >>> result.startswith('0001-01-01T')
916 True
917 """
918 if isinstance(obj, datetime):
919 return obj.isoformat()
920 return obj
923def validate_password_strength(password: str) -> tuple[bool, str]:
924 """Validate password meets strength requirements.
926 Uses configurable settings from config.py for password policy.
927 Respects password_policy_enabled toggle - if disabled, all passwords pass.
929 Args:
930 password: Password to validate
932 Returns:
933 tuple: (is_valid, error_message)
934 """
935 # If password policy is disabled, skip all validation
936 if not getattr(settings, "password_policy_enabled", True):
937 return True, ""
939 min_length = getattr(settings, "password_min_length", 8)
940 require_uppercase = getattr(settings, "password_require_uppercase", False)
941 require_lowercase = getattr(settings, "password_require_lowercase", False)
942 require_numbers = getattr(settings, "password_require_numbers", False)
943 require_special = getattr(settings, "password_require_special", False)
945 if len(password) < min_length:
946 return False, f"Password must be at least {min_length} characters long"
948 if require_uppercase and not any(c.isupper() for c in password):
949 return False, "Password must contain at least one uppercase letter (A-Z)"
951 if require_lowercase and not any(c.islower() for c in password):
952 return False, "Password must contain at least one lowercase letter (a-z)"
954 if require_numbers and not any(c.isdigit() for c in password):
955 return False, "Password must contain at least one number (0-9)"
957 # Match the special character set used in EmailAuthService
958 special_chars = '!@#$%^&*(),.?":{}|<>'
959 if require_special and not any(c in special_chars for c in password):
960 return False, f"Password must contain at least one special character ({special_chars})"
962 return True, ""
965admin_router = APIRouter(prefix="/admin", tags=["Admin UI"])
967####################
968# Admin UI Routes #
969####################
972def _escape_like(value: str) -> str:
973 """Escape SQL LIKE wildcard characters.
975 Args:
976 value (str): Raw search string.
978 Returns:
979 str: Escaped string safe for use in ``LIKE`` expressions with ``ESCAPE '\\'``.
980 """
981 return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
984def _like_contains(column, value: str):
985 """Case-insensitive substring match with proper LIKE wildcard escaping.
987 Wraps the escaped *value* with ``%`` wildcards and adds an explicit
988 ``ESCAPE '\\\\'`` clause so that ``%`` and ``_`` in the search term are
989 treated literally on all backends (SQLite requires the clause).
991 Args:
992 column: SQLAlchemy column expression (pre-wrapped with ``func.lower``
993 / ``coalesce`` as needed by the caller).
994 value: Raw search term — escaping is applied internally.
996 Returns:
997 A SQLAlchemy binary expression suitable for ``.where()``.
998 """
999 return column.like("%" + _escape_like(value) + "%", escape="\\")
1002async def _get_user_team_ids(user: dict, db: Session) -> list:
1003 """Return team IDs for the authenticated user.
1005 When called from :func:`admin_unified_search`, the user dict carries a
1006 ``_cached_team_ids`` key so the expensive lookup is executed only once
1007 per request instead of once per entity type.
1009 If the auth context includes explicit ``token_teams`` (API tokens), the
1010 returned IDs are derived from that token scope so search endpoints cannot
1011 return entities outside the token's team restrictions.
1013 Args:
1014 user (dict): Authenticated user context.
1015 db (Session): Database session.
1017 Returns:
1018 list: Team ID list for the user.
1019 """
1020 cached = user.get("_cached_team_ids")
1021 if cached is not None:
1022 return cached
1024 if "token_teams" in user:
1025 token_teams = user.get("token_teams")
1026 if token_teams is not None:
1027 team_ids: list[str] = []
1028 for team in token_teams:
1029 if isinstance(team, dict):
1030 team_id = team.get("id")
1031 if isinstance(team_id, str) and team_id:
1032 team_ids.append(team_id)
1033 elif isinstance(team, str) and team:
1034 team_ids.append(team)
1035 return team_ids
1037 user_email = get_user_email(user)
1038 team_service = TeamManagementService(db)
1039 user_teams = await team_service.get_user_teams(user_email)
1040 return [t.id for t in user_teams]
1043def _is_explicit_token_team_scope(user: Any) -> bool:
1044 """Return whether the auth context carries explicit token team scope.
1046 Tokens with ``token_teams`` present and not ``None`` are scope-constrained
1047 (including public-only tokens with ``[]``). ``None`` denotes admin bypass.
1049 Args:
1050 user (Any): Authenticated user context.
1052 Returns:
1053 bool: True when ``token_teams`` is present and not ``None``.
1054 """
1055 if not isinstance(user, dict):
1056 return False
1057 return "token_teams" in user and user.get("token_teams") is not None
1060def _owner_access_condition(owner_column, team_column, *, user_email: str, team_ids: list[str], user: Any):
1061 """Build owner visibility predicate honoring token team scoping.
1063 For explicit token scopes, owner visibility is constrained to token teams.
1064 For legacy/session contexts without explicit scope (or admin bypass), keep
1065 existing owner visibility semantics.
1067 Args:
1068 owner_column: SQLAlchemy owner-email column expression.
1069 team_column: SQLAlchemy team-id column expression.
1070 user_email (str): Current user email.
1071 team_ids (list[str]): Team IDs visible to this auth context.
1072 user (Any): Authenticated user context.
1074 Returns:
1075 Any: SQLAlchemy boolean predicate for owner visibility.
1076 """
1077 if _is_explicit_token_team_scope(user):
1078 if not team_ids:
1079 return false()
1080 return and_(owner_column == user_email, team_column.in_(team_ids))
1081 return owner_column == user_email
1084async def _has_permission(
1085 *,
1086 db: Session,
1087 user: dict,
1088 permission: str,
1089 team_id: Optional[str] = None,
1090 allow_admin_bypass: bool = False,
1091 check_any_team: bool = False,
1092) -> bool:
1093 """Check a permission for the current user context.
1095 Args:
1096 db (Session): Database session.
1097 user (dict): Authenticated user context.
1098 permission (str): Permission to evaluate.
1099 team_id (Optional[str]): Optional team scope for the permission check.
1100 allow_admin_bypass (bool): Whether admin bypass is allowed.
1101 check_any_team (bool): Whether to check across all team-scoped roles.
1103 Returns:
1104 bool: True when permission is granted.
1105 """
1106 permission_service = PermissionService(db)
1107 return await permission_service.check_permission(
1108 user_email=get_user_email(user),
1109 permission=permission,
1110 team_id=team_id,
1111 ip_address=user.get("ip_address"),
1112 user_agent=user.get("user_agent"),
1113 allow_admin_bypass=allow_admin_bypass,
1114 check_any_team=check_any_team,
1115 )
1118def _normalize_search_query(query: Optional[str]) -> str:
1119 """Normalize search query values for consistent filtering.
1121 Args:
1122 query (Optional[str]): Raw query value or FastAPI ``Query`` wrapper.
1124 Returns:
1125 str: Lowercased, trimmed query string (empty string when unset).
1126 """
1127 if query is None:
1128 return ""
1129 if isinstance(query, str):
1130 return query.strip().lower()
1132 # Support direct unit-test invocation where FastAPI Query(...) defaults
1133 # can be passed through instead of resolved string values.
1134 default_value = getattr(query, "default", None)
1135 if default_value is None:
1136 return ""
1137 if isinstance(default_value, str):
1138 return default_value.strip().lower()
1139 return str(default_value).strip().lower()
1142def _normalize_tags_query(tags: Any) -> str:
1143 """Normalize tags query values.
1145 Handles plain strings and FastAPI `Query(...)` defaults when handlers are
1146 called directly in unit tests.
1148 Args:
1149 tags (Any): Raw tags value or FastAPI ``Query`` wrapper.
1151 Returns:
1152 str: Trimmed tags expression (empty string when unset).
1153 """
1154 if tags is None:
1155 return ""
1156 if isinstance(tags, str):
1157 return tags.strip()
1159 default_value = getattr(tags, "default", None)
1160 if default_value is None:
1161 return ""
1162 if isinstance(default_value, str):
1163 return default_value.strip()
1164 return str(default_value).strip()
1167def _normalize_int_query(value: Any, fallback: int) -> int:
1168 """Normalize integer query values, including FastAPI Query defaults.
1170 Args:
1171 value (Any): Raw integer value or FastAPI ``Query`` wrapper.
1172 fallback (int): Fallback value when normalization fails.
1174 Returns:
1175 int: Normalized integer value.
1176 """
1177 if isinstance(value, int):
1178 return value
1180 default_value = getattr(value, "default", None)
1181 if isinstance(default_value, int):
1182 return default_value
1184 try:
1185 return int(value)
1186 except (TypeError, ValueError):
1187 return fallback
1190_TAG_MAX_GROUPS = 20
1191_TAG_MAX_TERMS_PER_GROUP = 10
1194def _parse_tag_filter_groups(tags: Optional[str]) -> list[list[str]]:
1195 """Parse tag filter expressions.
1197 Expression syntax:
1198 - `,` separates OR groups (capped at :data:`_TAG_MAX_GROUPS`)
1199 - `+` separates AND terms inside a group (capped at :data:`_TAG_MAX_TERMS_PER_GROUP`)
1201 Examples:
1202 - `"prod,staging"` => [["prod"], ["staging"]]
1203 - `"mcp+critical"` => [["mcp", "critical"]]
1204 - `"mcp+critical,ui"` => [["mcp", "critical"], ["ui"]]
1206 Args:
1207 tags (Optional[str]): Tag expression with comma-separated OR groups and
1208 plus-separated AND terms.
1210 Returns:
1211 list[list[str]]: Parsed tag groups ready for SQL filter construction.
1212 """
1213 if not tags:
1214 return []
1216 groups: list[list[str]] = []
1217 for raw_group in tags.split(","):
1218 if len(groups) >= _TAG_MAX_GROUPS:
1219 break
1220 candidate = [term.strip() for term in raw_group.split("+") if term.strip()][:_TAG_MAX_TERMS_PER_GROUP]
1221 if candidate:
1222 groups.append(candidate)
1223 return groups
1226def _apply_tag_filter_groups(query: Any, db: Session, column: Any, tag_groups: list[list[str]]) -> Any:
1227 """Apply parsed tag filter groups to a SQLAlchemy query.
1229 Args:
1230 query (Any): SQLAlchemy ``select`` query to update.
1231 db (Session): Database session.
1232 column (Any): SQLAlchemy model column containing tags.
1233 tag_groups (list[list[str]]): Parsed OR-of-AND tag groups.
1235 Returns:
1236 Any: Updated query with tag filters applied.
1237 """
1238 if not tag_groups:
1239 return query
1241 group_exprs = []
1242 for group in tag_groups:
1243 # Single term group => OR semantics (term exists)
1244 # Multi-term group => AND semantics (all terms exist)
1245 group_exprs.append(json_contains_tag_expr(db, column, group, match_any=len(group) == 1))
1247 if len(group_exprs) == 1:
1248 return query.where(group_exprs[0])
1249 return query.where(or_(*group_exprs))
1252def _build_search_response(
1253 *,
1254 entity_key: str,
1255 entity_type: str,
1256 items: list[dict[str, Any]],
1257 query: str,
1258 tags: str,
1259 tag_groups: list[list[str]],
1260) -> dict[str, Any]:
1261 """Build a consistent search response while preserving legacy keys.
1263 Args:
1264 entity_key (str): Legacy entity key (for example ``tools``).
1265 entity_type (str): Canonical entity type label.
1266 items (list[dict[str, Any]]): Serialized entity items.
1267 query (str): Normalized free-text query.
1268 tags (str): Normalized tag expression.
1269 tag_groups (list[list[str]]): Parsed tag groups.
1271 Returns:
1272 dict[str, Any]: Unified search payload with legacy and standard keys.
1273 """
1274 filters_applied = {"q": query, "tags": tags, "tag_groups": tag_groups}
1275 return {
1276 entity_key: items, # legacy key for backward compatibility
1277 "items": items,
1278 "count": len(items),
1279 "entity_type": entity_type,
1280 "query": query,
1281 "filters_applied": filters_applied,
1282 }
1285@admin_router.get("/overview/partial")
1286@require_permission("admin.overview", allow_admin_bypass=False)
1287async def get_overview_partial(
1288 request: Request,
1289 db: Session = Depends(get_db),
1290 user=Depends(get_current_user_with_permissions),
1291) -> HTMLResponse:
1292 """Render the overview dashboard partial HTML template.
1294 This endpoint returns a rendered HTML partial containing an architecture
1295 diagram showing ContextForge inputs (Virtual Servers), middleware (Plugins),
1296 and outputs (A2A Agents, MCP Gateways, Tools, etc.) along with key metrics.
1298 Args:
1299 request: FastAPI request object
1300 db: Database session
1301 user: Authenticated user
1303 Returns:
1304 HTMLResponse with rendered overview partial template
1305 """
1306 LOGGER.debug(f"User {get_user_email(user)} requested overview partial")
1308 try:
1309 # Gather counts for all entity types
1310 # Note: SQLAlchemy func.count requires pylint disable=not-callable
1311 # Virtual Servers (inputs) - uses 'enabled' field
1312 servers_total = db.query(func.count(DbServer.id)).scalar() or 0 # pylint: disable=not-callable
1313 servers_active = db.query(func.count(DbServer.id)).filter(DbServer.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable
1315 # MCP Gateways - uses 'enabled' field
1316 gateways_total = db.query(func.count(DbGateway.id)).scalar() or 0 # pylint: disable=not-callable
1317 gateways_active = db.query(func.count(DbGateway.id)).filter(DbGateway.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable
1319 # A2A Agents (if enabled) - uses 'enabled' field
1320 a2a_total = 0
1321 a2a_active = 0
1322 if settings.mcpgateway_a2a_enabled:
1323 a2a_total = db.query(func.count(DbA2AAgent.id)).scalar() or 0 # pylint: disable=not-callable
1324 a2a_active = db.query(func.count(DbA2AAgent.id)).filter(DbA2AAgent.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable
1326 # Tools - uses 'enabled' field
1327 tools_total = db.query(func.count(DbTool.id)).scalar() or 0 # pylint: disable=not-callable
1328 tools_active = db.query(func.count(DbTool.id)).filter(DbTool.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable
1330 # Prompts - uses 'enabled' field
1331 prompts_total = db.query(func.count(DbPrompt.id)).scalar() or 0 # pylint: disable=not-callable
1332 prompts_active = db.query(func.count(DbPrompt.id)).filter(DbPrompt.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable
1334 # Resources - uses 'enabled' field
1335 resources_total = db.query(func.count(DbResource.id)).scalar() or 0 # pylint: disable=not-callable
1336 resources_active = db.query(func.count(DbResource.id)).filter(DbResource.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable
1338 # Plugin stats
1339 overview_plugin_service = get_plugin_service()
1340 plugin_manager = getattr(request.app.state, "plugin_manager", None)
1341 if plugin_manager:
1342 overview_plugin_service.set_plugin_manager(plugin_manager)
1343 plugin_stats = await overview_plugin_service.get_plugin_statistics()
1345 # Infrastructure status (database, cache, uptime)
1346 _, db_reachable = version_module._database_version() # pylint: disable=protected-access
1347 db_dialect = version_module.engine.dialect.name
1348 cache_type = settings.cache_type
1349 uptime_seconds = int(time.time() - version_module.START_TIME)
1351 # Redis status (if applicable)
1352 redis_available = version_module.REDIS_AVAILABLE
1353 redis_reachable = False
1354 if redis_available and cache_type.lower() == "redis" and settings.redis_url:
1355 try:
1356 # First-Party
1357 from mcpgateway.utils.redis_client import is_redis_available # pylint: disable=import-outside-toplevel
1359 redis_reachable = await is_redis_available()
1360 except Exception:
1361 redis_reachable = False
1363 # Aggregate metrics from services
1364 overview_tool_service = ToolService()
1365 overview_server_service = ServerService()
1366 overview_prompt_service = PromptService()
1367 overview_resource_service = ResourceService()
1369 tool_metrics = await overview_tool_service.aggregate_metrics(db)
1370 server_metrics = await overview_server_service.aggregate_metrics(db)
1371 prompt_metrics = await overview_prompt_service.aggregate_metrics(db)
1372 resource_metrics = await overview_resource_service.aggregate_metrics(db)
1374 # Calculate totals
1375 total_executions = (
1376 (tool_metrics.get("total_executions", 0) if isinstance(tool_metrics, dict) else getattr(tool_metrics, "total_executions", 0))
1377 + (server_metrics.total_executions if hasattr(server_metrics, "total_executions") else server_metrics.get("total_executions", 0))
1378 + (prompt_metrics.get("total_executions", 0) if isinstance(prompt_metrics, dict) else getattr(prompt_metrics, "total_executions", 0))
1379 + (resource_metrics.total_executions if hasattr(resource_metrics, "total_executions") else resource_metrics.get("total_executions", 0))
1380 )
1382 successful_executions = (
1383 (tool_metrics.get("successful_executions", 0) if isinstance(tool_metrics, dict) else getattr(tool_metrics, "successful_executions", 0))
1384 + (server_metrics.successful_executions if hasattr(server_metrics, "successful_executions") else server_metrics.get("successful_executions", 0))
1385 + (prompt_metrics.get("successful_executions", 0) if isinstance(prompt_metrics, dict) else getattr(prompt_metrics, "successful_executions", 0))
1386 + (resource_metrics.successful_executions if hasattr(resource_metrics, "successful_executions") else resource_metrics.get("successful_executions", 0))
1387 )
1389 success_rate = (successful_executions / total_executions * 100) if total_executions > 0 else 100.0
1391 # Calculate average latency across all services
1392 latencies = []
1393 for m in [tool_metrics, server_metrics, prompt_metrics, resource_metrics]:
1394 avg_time = m.get("avg_response_time") if isinstance(m, dict) else getattr(m, "avg_response_time", None)
1395 if avg_time is not None:
1396 latencies.append(avg_time)
1397 avg_latency = sum(latencies) / len(latencies) if latencies else 0.0
1399 # Prepare context
1400 context = {
1401 "request": request,
1402 "root_path": request.scope.get("root_path", ""),
1403 # Inputs
1404 "servers_total": servers_total,
1405 "servers_active": servers_active,
1406 # Outputs
1407 "gateways_total": gateways_total,
1408 "gateways_active": gateways_active,
1409 "a2a_total": a2a_total,
1410 "a2a_active": a2a_active,
1411 "a2a_enabled": settings.mcpgateway_a2a_enabled,
1412 "tools_total": tools_total,
1413 "tools_active": tools_active,
1414 "prompts_total": prompts_total,
1415 "prompts_active": prompts_active,
1416 "resources_total": resources_total,
1417 "resources_active": resources_active,
1418 # Plugins (plugin_stats can be dict or PluginStatsResponse)
1419 "plugins_total": plugin_stats.get("total_plugins", 0) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "total_plugins", 0),
1420 "plugins_enabled": plugin_stats.get("enabled_plugins", 0) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "enabled_plugins", 0),
1421 "plugins_by_hook": plugin_stats.get("plugins_by_hook", {}) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "plugins_by_hook", {}),
1422 # Metrics
1423 "total_executions": total_executions,
1424 "success_rate": success_rate,
1425 "avg_latency_ms": avg_latency * 1000 if avg_latency else 0.0,
1426 # Version
1427 "version": __version__,
1428 # Infrastructure
1429 "db_dialect": db_dialect,
1430 "db_reachable": db_reachable,
1431 "cache_type": cache_type,
1432 "redis_available": redis_available,
1433 "redis_reachable": redis_reachable,
1434 "uptime_seconds": uptime_seconds,
1435 }
1437 return request.app.state.templates.TemplateResponse(request, "overview_partial.html", context)
1439 except Exception as e:
1440 LOGGER.error(f"Error rendering overview partial: {e}")
1441 error_html = f"""
1442 <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
1443 <strong class="font-bold">Error loading overview:</strong>
1444 <span class="block sm:inline">{html.escape(str(e))}</span>
1445 </div>
1446 """
1447 return HTMLResponse(content=error_html, status_code=500)
1450@admin_router.get("/config/passthrough-headers", response_model=GlobalConfigRead)
1451@require_permission("admin.system_config", allow_admin_bypass=False)
1452@rate_limit(requests_per_minute=30) # Lower limit for config endpoints
1453async def get_global_passthrough_headers(
1454 db: Session = Depends(get_db),
1455 _user=Depends(get_current_user_with_permissions),
1456) -> GlobalConfigRead:
1457 """Get the global passthrough headers configuration.
1459 Args:
1460 db: Database session
1461 _user: Authenticated user
1463 Returns:
1464 GlobalConfigRead: The current global passthrough headers configuration
1466 Examples:
1467 >>> # Test function exists and has correct name
1468 >>> from mcpgateway.admin import get_global_passthrough_headers
1469 >>> get_global_passthrough_headers.__name__
1470 'get_global_passthrough_headers'
1471 >>> # Test it's a coroutine function
1472 >>> import inspect
1473 >>> inspect.iscoroutinefunction(get_global_passthrough_headers)
1474 True
1475 """
1476 # Use cache for reads (Issue #1715)
1477 # Pass env defaults so env/merge modes return correct headers
1478 passthrough_headers = global_config_cache.get_passthrough_headers(db, settings.default_passthrough_headers)
1479 return GlobalConfigRead(passthrough_headers=passthrough_headers)
1482@admin_router.put("/config/passthrough-headers", response_model=GlobalConfigRead)
1483@require_permission("admin.system_config", allow_admin_bypass=False)
1484@rate_limit(requests_per_minute=20) # Stricter limit for config updates
1485async def update_global_passthrough_headers(
1486 request: Request, # pylint: disable=unused-argument
1487 config_update: GlobalConfigUpdate,
1488 db: Session = Depends(get_db),
1489 _user=Depends(get_current_user_with_permissions),
1490) -> GlobalConfigRead:
1491 """Update the global passthrough headers configuration.
1493 Args:
1494 request: HTTP request object
1495 config_update: The new configuration
1496 db: Database session
1497 _user: Authenticated user
1499 Raises:
1500 HTTPException: If there is a conflict or validation error
1502 Returns:
1503 GlobalConfigRead: The updated configuration
1505 Examples:
1506 >>> # Test function exists and has correct name
1507 >>> from mcpgateway.admin import update_global_passthrough_headers
1508 >>> update_global_passthrough_headers.__name__
1509 'update_global_passthrough_headers'
1510 >>> # Test it's a coroutine function
1511 >>> import inspect
1512 >>> inspect.iscoroutinefunction(update_global_passthrough_headers)
1513 True
1514 """
1515 try:
1516 config = db.query(GlobalConfig).first()
1517 if not config:
1518 config = GlobalConfig(passthrough_headers=config_update.passthrough_headers)
1519 db.add(config)
1520 else:
1521 config.passthrough_headers = config_update.passthrough_headers
1522 db.commit()
1523 # Invalidate cache so changes propagate immediately (Issue #1715)
1524 global_config_cache.invalidate()
1525 return GlobalConfigRead(passthrough_headers=config.passthrough_headers)
1526 except IntegrityError as e:
1527 db.rollback()
1528 raise HTTPException(status_code=409, detail="Passthrough headers conflict") from e
1529 except ValidationError as e:
1530 db.rollback()
1531 raise HTTPException(status_code=422, detail="Invalid passthrough headers format") from e
1532 except PassthroughHeadersError as e:
1533 db.rollback()
1534 raise HTTPException(status_code=500, detail=str(e)) from e
1537@admin_router.post("/config/passthrough-headers/invalidate-cache")
1538@require_permission("admin.system_config", allow_admin_bypass=False)
1539@rate_limit(requests_per_minute=10) # Strict limit for cache operations
1540async def invalidate_passthrough_headers_cache(
1541 _user=Depends(get_current_user_with_permissions),
1542 _db: Session = Depends(get_db),
1543) -> Dict[str, Any]:
1544 """Invalidate the GlobalConfig cache.
1546 Forces an immediate cache refresh on the next access. Use this after
1547 updating GlobalConfig outside the normal API flow, or when you need
1548 changes to propagate immediately across all workers.
1550 Args:
1551 _user: Authenticated user
1552 _db: Database session for permission checks.
1554 Returns:
1555 Dict with invalidation status and cache statistics
1557 Examples:
1558 >>> # Test function exists and has correct name
1559 >>> from mcpgateway.admin import invalidate_passthrough_headers_cache
1560 >>> invalidate_passthrough_headers_cache.__name__
1561 'invalidate_passthrough_headers_cache'
1562 >>> # Test it's a coroutine function
1563 >>> import inspect
1564 >>> inspect.iscoroutinefunction(invalidate_passthrough_headers_cache)
1565 True
1566 """
1567 global_config_cache.invalidate()
1568 stats = global_config_cache.stats()
1569 return {
1570 "status": "invalidated",
1571 "message": "GlobalConfig cache invalidated successfully",
1572 "cache_stats": stats,
1573 }
1576@admin_router.get("/config/passthrough-headers/cache-stats")
1577@require_permission("admin.system_config", allow_admin_bypass=False)
1578@rate_limit(requests_per_minute=30)
1579async def get_passthrough_headers_cache_stats(
1580 _user=Depends(get_current_user_with_permissions),
1581 _db: Session = Depends(get_db),
1582) -> Dict[str, Any]:
1583 """Get GlobalConfig cache statistics.
1585 Returns cache hit/miss counts, hit rate, TTL, and current cache status.
1586 Useful for monitoring cache effectiveness and debugging.
1588 Args:
1589 _user: Authenticated user
1590 _db: Database session for permission checks.
1592 Returns:
1593 Dict with cache statistics
1595 Examples:
1596 >>> # Test function exists and has correct name
1597 >>> from mcpgateway.admin import get_passthrough_headers_cache_stats
1598 >>> get_passthrough_headers_cache_stats.__name__
1599 'get_passthrough_headers_cache_stats'
1600 >>> # Test it's a coroutine function
1601 >>> import inspect
1602 >>> inspect.iscoroutinefunction(get_passthrough_headers_cache_stats)
1603 True
1604 """
1605 return global_config_cache.stats()
1608# ===================================
1609# A2A Stats Cache Endpoints
1610# ===================================
1613@admin_router.post("/cache/a2a-stats/invalidate")
1614@require_permission("admin.system_config", allow_admin_bypass=False)
1615@rate_limit(requests_per_minute=10)
1616async def invalidate_a2a_stats_cache(
1617 _user=Depends(get_current_user_with_permissions),
1618 _db: Session = Depends(get_db),
1619) -> Dict[str, Any]:
1620 """Invalidate the A2A stats cache.
1622 Forces an immediate cache refresh on the next access. Use this after
1623 modifying A2A agents outside the normal API flow, or when you need
1624 changes to propagate immediately.
1626 Args:
1627 _user: Authenticated user
1628 _db: Database session for permission checks.
1630 Returns:
1631 Dict with invalidation status and cache statistics
1633 Examples:
1634 >>> from mcpgateway.admin import invalidate_a2a_stats_cache
1635 >>> invalidate_a2a_stats_cache.__name__
1636 'invalidate_a2a_stats_cache'
1637 >>> import inspect
1638 >>> inspect.iscoroutinefunction(invalidate_a2a_stats_cache)
1639 True
1640 """
1641 a2a_stats_cache.invalidate()
1642 stats = a2a_stats_cache.stats()
1643 return {
1644 "status": "invalidated",
1645 "message": "A2A stats cache invalidated successfully",
1646 "cache_stats": stats,
1647 }
1650@admin_router.get("/cache/a2a-stats/stats")
1651@require_permission("admin.system_config", allow_admin_bypass=False)
1652@rate_limit(requests_per_minute=30)
1653async def get_a2a_stats_cache_stats(
1654 _user=Depends(get_current_user_with_permissions),
1655 _db: Session = Depends(get_db),
1656) -> Dict[str, Any]:
1657 """Get A2A stats cache statistics.
1659 Returns cache hit/miss counts, hit rate, TTL, and current cache status.
1660 Useful for monitoring cache effectiveness and debugging.
1662 Args:
1663 _user: Authenticated user
1664 _db: Database session for permission checks.
1666 Returns:
1667 Dict with cache statistics
1669 Examples:
1670 >>> from mcpgateway.admin import get_a2a_stats_cache_stats
1671 >>> get_a2a_stats_cache_stats.__name__
1672 'get_a2a_stats_cache_stats'
1673 >>> import inspect
1674 >>> inspect.iscoroutinefunction(get_a2a_stats_cache_stats)
1675 True
1676 """
1677 return a2a_stats_cache.stats()
1680@admin_router.get("/mcp-pool/metrics")
1681@require_permission("admin.system_config", allow_admin_bypass=False)
1682@rate_limit(requests_per_minute=60)
1683async def get_mcp_session_pool_metrics(
1684 request: Request, # pylint: disable=unused-argument
1685 _user=Depends(get_current_user_with_permissions),
1686 _db: Session = Depends(get_db),
1687) -> Dict[str, Any]:
1688 """Get MCP session pool metrics.
1690 Returns pool statistics including hits, misses, evictions, hit rate,
1691 circuit breaker status, and per-pool details. Useful for monitoring
1692 pool effectiveness and diagnosing connection issues.
1694 Args:
1695 request: HTTP request object (required by rate_limit decorator)
1696 _user: Authenticated user
1697 _db: Database session for permission checks.
1699 Returns:
1700 Dict with pool metrics including:
1701 - hits: Number of pool hits (session reuse)
1702 - misses: Number of pool misses (new session created)
1703 - evictions: Number of sessions evicted due to TTL
1704 - health_check_failures: Number of failed health checks
1705 - circuit_breaker_trips: Number of circuit breaker activations
1706 - pool_keys_evicted: Number of idle pool keys cleaned up
1707 - sessions_reaped: Number of stale sessions closed by background reaper
1708 - hit_rate: Ratio of hits to total requests (0.0-1.0)
1709 - pool_key_count: Number of active pool keys
1710 - pools: Per-pool statistics (available, active, max)
1711 - circuit_breakers: Circuit breaker status per URL
1713 Raises:
1714 HTTPException: If session pool is not initialized
1716 Examples:
1717 >>> from mcpgateway.admin import get_mcp_session_pool_metrics
1718 >>> get_mcp_session_pool_metrics.__name__
1719 'get_mcp_session_pool_metrics'
1720 >>> import inspect
1721 >>> inspect.iscoroutinefunction(get_mcp_session_pool_metrics)
1722 True
1723 """
1724 if not settings.mcp_session_pool_enabled:
1725 return {"enabled": False, "message": "MCP session pool is disabled"}
1727 try:
1728 pool = get_mcp_session_pool()
1729 metrics = pool.get_metrics()
1730 return {"enabled": True, **metrics}
1731 except RuntimeError as e:
1732 return {"enabled": True, "error": str(e), "message": "Pool not yet initialized"}
1735@admin_router.get("/config/settings")
1736@require_permission("admin.system_config", allow_admin_bypass=False)
1737async def get_configuration_settings(
1738 _db: Session = Depends(get_db),
1739 _user=Depends(get_current_user_with_permissions),
1740) -> Dict[str, Any]:
1741 """Get application configuration settings grouped by category.
1743 Returns configuration settings with sensitive values masked.
1745 Args:
1746 _db: Database session
1747 _user: Authenticated user
1749 Returns:
1750 Dict with configuration groups and their settings
1751 """
1753 def mask_sensitive(value: Any, key: str) -> Any:
1754 """Mask sensitive configuration values.
1756 Args:
1757 value: Configuration value to potentially mask
1758 key: Configuration key name to check for sensitive patterns
1760 Returns:
1761 Masked value if sensitive, original value otherwise
1762 """
1763 sensitive_keys = {"password", "secret", "key", "token", "credentials", "client_secret", "private_key", "auth_encryption_secret"}
1764 if any(s in key.lower() for s in sensitive_keys):
1765 # Handle SecretStr objects
1766 if isinstance(value, SecretStr):
1767 return settings.masked_auth_value
1768 if value and str(value) not in ["", "None", "null"]:
1769 return settings.masked_auth_value
1770 return value
1772 # Group settings by category
1773 config_groups = {
1774 "Basic Settings": {
1775 "app_name": settings.app_name,
1776 "host": settings.host,
1777 "port": settings.port,
1778 "environment": settings.environment,
1779 "app_domain": str(settings.app_domain),
1780 "protocol_version": settings.protocol_version,
1781 },
1782 "Authentication & Security": {
1783 "auth_required": settings.auth_required,
1784 "basic_auth_user": settings.basic_auth_user,
1785 "basic_auth_password": mask_sensitive(settings.basic_auth_password, "password"),
1786 "jwt_algorithm": settings.jwt_algorithm,
1787 "jwt_secret_key": mask_sensitive(settings.jwt_secret_key, "secret_key"),
1788 "jwt_audience": settings.jwt_audience,
1789 "jwt_issuer": settings.jwt_issuer,
1790 "token_expiry": settings.token_expiry,
1791 "require_token_expiration": settings.require_token_expiration,
1792 "mcp_client_auth_enabled": settings.mcp_client_auth_enabled,
1793 "trust_proxy_auth": settings.trust_proxy_auth,
1794 "skip_ssl_verify": settings.skip_ssl_verify,
1795 },
1796 "SSO Configuration": {
1797 "sso_enabled": settings.sso_enabled,
1798 "sso_github_enabled": settings.sso_github_enabled,
1799 "sso_google_enabled": settings.sso_google_enabled,
1800 "sso_ibm_verify_enabled": settings.sso_ibm_verify_enabled,
1801 "sso_okta_enabled": settings.sso_okta_enabled,
1802 "sso_keycloak_enabled": settings.sso_keycloak_enabled,
1803 "sso_entra_enabled": settings.sso_entra_enabled,
1804 "sso_generic_enabled": settings.sso_generic_enabled,
1805 "sso_auto_create_users": settings.sso_auto_create_users,
1806 "sso_preserve_admin_auth": settings.sso_preserve_admin_auth,
1807 "sso_require_admin_approval": settings.sso_require_admin_approval,
1808 },
1809 "Email Authentication": {
1810 "email_auth_enabled": settings.email_auth_enabled,
1811 "platform_admin_email": settings.platform_admin_email,
1812 "platform_admin_password": mask_sensitive(settings.platform_admin_password, "password"),
1813 },
1814 "Database & Cache": {
1815 "database_url": settings.database_url.replace("://", "://***@") if "@" in settings.database_url else settings.database_url,
1816 "cache_type": settings.cache_type,
1817 "redis_url": settings.redis_url.replace("://", "://***@") if settings.redis_url and "@" in settings.redis_url else settings.redis_url,
1818 "db_pool_size": settings.db_pool_size,
1819 "db_max_overflow": settings.db_max_overflow,
1820 },
1821 "Feature Flags": {
1822 "mcpgateway_ui_enabled": settings.mcpgateway_ui_enabled,
1823 "mcpgateway_admin_api_enabled": settings.mcpgateway_admin_api_enabled,
1824 "mcpgateway_bulk_import_enabled": settings.mcpgateway_bulk_import_enabled,
1825 "mcpgateway_a2a_enabled": settings.mcpgateway_a2a_enabled,
1826 "mcpgateway_catalog_enabled": settings.mcpgateway_catalog_enabled,
1827 "plugins_enabled": settings.plugins_enabled,
1828 "well_known_enabled": settings.well_known_enabled,
1829 "mcpgateway_direct_proxy_enabled": settings.mcpgateway_direct_proxy_enabled,
1830 },
1831 "Connection Timeouts": {
1832 "federation_timeout": settings.federation_timeout, # Gateway/server HTTP request timeout
1833 "mcpgateway_direct_proxy_timeout": settings.mcpgateway_direct_proxy_timeout,
1834 },
1835 "Transport": {
1836 "transport_type": settings.transport_type,
1837 "websocket_ping_interval": settings.websocket_ping_interval,
1838 "sse_retry_timeout": settings.sse_retry_timeout,
1839 "sse_keepalive_enabled": settings.sse_keepalive_enabled,
1840 },
1841 "Logging": {
1842 "log_level": settings.log_level,
1843 "log_format": settings.log_format,
1844 "log_to_file": settings.log_to_file,
1845 "log_file": settings.log_file,
1846 "log_rotation_enabled": settings.log_rotation_enabled,
1847 },
1848 "Resources & Tools": {
1849 "tool_timeout": settings.tool_timeout,
1850 "tool_rate_limit": settings.tool_rate_limit,
1851 "tool_concurrent_limit": settings.tool_concurrent_limit,
1852 "resource_cache_size": settings.resource_cache_size,
1853 "resource_cache_ttl": settings.resource_cache_ttl,
1854 "max_resource_size": settings.max_resource_size,
1855 },
1856 "CORS Settings": {
1857 "cors_enabled": settings.cors_enabled,
1858 "allowed_origins": list(settings.allowed_origins),
1859 "cors_allow_credentials": settings.cors_allow_credentials,
1860 },
1861 "Security Headers": {
1862 "security_headers_enabled": settings.security_headers_enabled,
1863 "x_frame_options": settings.x_frame_options,
1864 "hsts_enabled": settings.hsts_enabled,
1865 "hsts_max_age": settings.hsts_max_age,
1866 "remove_server_headers": settings.remove_server_headers,
1867 },
1868 "Observability": {
1869 "otel_enable_observability": settings.otel_enable_observability,
1870 "otel_traces_exporter": settings.otel_traces_exporter,
1871 "otel_service_name": settings.otel_service_name,
1872 },
1873 "Development": {
1874 "dev_mode": settings.dev_mode,
1875 "reload": settings.reload,
1876 "debug": settings.debug,
1877 },
1878 }
1880 return {
1881 "groups": config_groups,
1882 "security_status": settings.get_security_status(),
1883 }
1886@admin_router.get("/servers", response_model=PaginatedResponse)
1887@require_permission("servers.read", allow_admin_bypass=False)
1888async def admin_list_servers(
1889 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
1890 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
1891 include_inactive: bool = False,
1892 db: Session = Depends(get_db),
1893 user=Depends(get_current_user_with_permissions),
1894) -> Dict[str, Any]:
1895 """
1896 List servers for the admin UI with pagination support.
1898 This endpoint retrieves a paginated list of servers from the database, optionally
1899 including those that are inactive. Uses offset-based (page/per_page) pagination.
1901 Args:
1902 page (int): Page number (1-indexed) for offset pagination.
1903 per_page (int): Number of items per page.
1904 include_inactive (bool): Whether to include inactive servers.
1905 db (Session): The database session dependency.
1906 user (str): The authenticated user dependency.
1908 Returns:
1909 Dict[str, Any]: A dictionary containing:
1910 - data: List of server records formatted with by_alias=True
1911 - pagination: Pagination metadata
1912 - links: Pagination links (optional)
1914 Examples:
1915 >>> callable(admin_list_servers)
1916 True
1917 >>> admin_list_servers.__name__
1918 'admin_list_servers'
1919 """
1920 LOGGER.debug(f"User {get_user_email(user)} requested server list (page={page}, per_page={per_page})")
1921 user_email = get_user_email(user)
1923 # Call server_service.list_servers with page-based pagination
1924 paginated_result = await server_service.list_servers(
1925 db=db,
1926 include_inactive=include_inactive,
1927 page=page,
1928 per_page=per_page,
1929 user_email=user_email,
1930 )
1932 # End the read-only transaction early to avoid idle-in-transaction under load.
1933 db.commit()
1935 # Return standardized paginated response
1936 return {
1937 "data": [server.model_dump(by_alias=True) for server in paginated_result["data"]],
1938 "pagination": paginated_result["pagination"].model_dump(),
1939 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
1940 }
1943@admin_router.get("/servers/partial", response_class=HTMLResponse)
1944@require_permission("servers.read", allow_admin_bypass=False)
1945async def admin_servers_partial_html(
1946 request: Request,
1947 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
1948 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
1949 q: str = Query("", description="Search query"),
1950 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
1951 include_inactive: bool = False,
1952 render: Optional[str] = Query(None),
1953 team_id: Optional[str] = Depends(_validated_team_id_param),
1954 db: Session = Depends(get_db),
1955 user=Depends(get_current_user_with_permissions),
1956):
1957 """Return paginated servers HTML partials for the admin UI.
1959 This HTMX endpoint returns only the partial HTML used by the admin UI for
1960 servers. It supports three render modes:
1962 - default: full table partial (rows + controls)
1963 - ``render="controls"``: return only pagination controls
1964 - ``render="selector"``: return selector items for infinite scroll
1966 Args:
1967 request (Request): FastAPI request object used by the template engine.
1968 page (int): Page number (1-indexed).
1969 per_page (int): Number of items per page (bounded by settings).
1970 q (str): Free-text query string.
1971 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
1972 include_inactive (bool): If True, include inactive servers in results.
1973 render (Optional[str]): Render mode; one of None, "controls", "selector".
1974 team_id (Optional[str]): Filter by team ID.
1975 db (Session): Database session (dependency-injected).
1976 user: Authenticated user object from dependency injection.
1978 Returns:
1979 Union[HTMLResponse, TemplateResponse]: A rendered template response
1980 containing either the table partial, pagination controls, or selector
1981 items depending on ``render``. The response contains JSON-serializable
1982 encoded server data when templates expect it.
1983 """
1984 LOGGER.debug(f"User {get_user_email(user)} requested servers HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, team_id={team_id})")
1985 search_query = _normalize_search_query(q)
1986 normalized_tags = _normalize_tags_query(tags)
1987 tag_groups = _parse_tag_filter_groups(normalized_tags)
1989 # Normalize per_page within configured bounds
1990 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
1992 user_email = get_user_email(user)
1994 # Team scoping
1995 team_ids = await _get_user_team_ids(user, db)
1997 # Build base query with eager loading to avoid N+1 queries
1998 query = select(DbServer).options(
1999 selectinload(DbServer.tools),
2000 selectinload(DbServer.resources),
2001 selectinload(DbServer.prompts),
2002 selectinload(DbServer.a2a_agents),
2003 joinedload(DbServer.email_team),
2004 )
2006 if not include_inactive:
2007 query = query.where(DbServer.enabled.is_(True))
2009 # Build access conditions
2010 # When team_id is specified, show ONLY items from that team (team-scoped view)
2011 # Otherwise, show all accessible items (All Teams view)
2012 if team_id:
2013 # Team-specific view: only show servers from the specified team
2014 if team_id in team_ids:
2015 # Apply visibility check: team/public resources + user's own resources (including private)
2016 team_access = [
2017 and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])),
2018 and_(DbServer.team_id == team_id, DbServer.owner_email == user_email),
2019 ]
2020 query = query.where(or_(*team_access))
2021 LOGGER.debug(f"Filtering servers by team_id: {team_id}")
2022 else:
2023 # User is not a member of this team, return no results using SQLAlchemy's false()
2024 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
2025 query = query.where(false())
2026 else:
2027 # All Teams view: apply standard access conditions (owner, team, public)
2028 access_conditions = []
2029 access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user))
2030 if team_ids:
2031 access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"])))
2032 access_conditions.append(DbServer.visibility == "public")
2033 query = query.where(or_(*access_conditions))
2035 if search_query:
2036 query = query.where(
2037 or_(
2038 _like_contains(func.lower(DbServer.id), search_query),
2039 _like_contains(func.lower(DbServer.name), search_query),
2040 _like_contains(func.lower(coalesce(DbServer.description, "")), search_query),
2041 )
2042 )
2044 query = _apply_tag_filter_groups(query, db, DbServer.tags, tag_groups)
2046 # Apply pagination ordering for cursor support
2047 query = query.order_by(desc(DbServer.created_at), desc(DbServer.id))
2049 # Build query params for pagination links
2050 query_params = {}
2051 if include_inactive:
2052 query_params["include_inactive"] = "true"
2053 if team_id:
2054 query_params["team_id"] = team_id
2055 if search_query:
2056 query_params["q"] = search_query
2057 if normalized_tags:
2058 query_params["tags"] = normalized_tags
2060 # Use unified pagination function
2061 root_path = request.scope.get("root_path", "")
2062 base_url = f"{root_path}/admin/servers/partial"
2063 paginated_result = await paginate_query(
2064 db=db,
2065 query=query,
2066 page=page,
2067 per_page=per_page,
2068 cursor=None, # HTMX partials use page-based navigation
2069 base_url=base_url,
2070 query_params=query_params,
2071 use_cursor_threshold=False, # Disable auto-cursor switching for UI
2072 )
2074 # Extract paginated servers (DbServer objects)
2075 servers_db = paginated_result["data"]
2076 pagination = paginated_result["pagination"]
2077 links = paginated_result["links"]
2079 # Team names are loaded via joinedload(DbServer.email_team) and accessed via server.team property
2081 # Batch convert to Pydantic models using server service
2082 # This eliminates the N+1 query problem from calling get_server_details() in a loop
2083 servers_pydantic = []
2084 failed_count = 0
2085 for s in servers_db:
2086 try:
2087 servers_pydantic.append(server_service.convert_server_to_read(s, include_metrics=False))
2088 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
2089 failed_count += 1
2090 LOGGER.exception(f"Failed to convert server {getattr(s, 'id', 'unknown')} ({getattr(s, 'name', 'unknown')}): {e}")
2091 _adjust_pagination_for_conversion_failures(pagination, failed_count)
2092 data = jsonable_encoder(servers_pydantic)
2094 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
2095 db.commit()
2097 if render == "controls":
2098 return request.app.state.templates.TemplateResponse(
2099 request,
2100 "pagination_controls.html",
2101 {
2102 "request": request,
2103 "pagination": pagination.model_dump(),
2104 "base_url": base_url,
2105 "hx_target": "#servers-table-body",
2106 "hx_indicator": "#servers-loading",
2107 "query_params": query_params,
2108 "root_path": request.scope.get("root_path", ""),
2109 },
2110 )
2112 if render == "selector":
2113 return request.app.state.templates.TemplateResponse(
2114 request,
2115 "servers_selector_items.html",
2116 {
2117 "request": request,
2118 "data": data,
2119 "pagination": pagination.model_dump(),
2120 "root_path": request.scope.get("root_path", ""),
2121 },
2122 )
2124 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
2125 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
2126 return request.app.state.templates.TemplateResponse(
2127 request,
2128 "servers_partial.html",
2129 {
2130 "request": request,
2131 "data": data,
2132 "pagination": pagination.model_dump(),
2133 "links": links.model_dump() if links else None,
2134 "root_path": request.scope.get("root_path", ""),
2135 "include_inactive": include_inactive,
2136 "query_params": query_params,
2137 "current_user_email": user_email,
2138 "is_admin": _is_admin,
2139 "user_team_roles": _team_roles,
2140 },
2141 )
2144@admin_router.get("/servers/{server_id}", response_model=ServerRead)
2145@require_permission("servers.read", allow_admin_bypass=False)
2146async def admin_get_server(server_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
2147 """
2148 Retrieve server details for the admin UI.
2150 Args:
2151 server_id (str): The ID of the server to retrieve.
2152 db (Session): The database session dependency.
2153 user (str): The authenticated user dependency.
2155 Returns:
2156 Dict[str, Any]: The server details.
2158 Raises:
2159 HTTPException: If the server is not found.
2160 Exception: For any other unexpected errors.
2162 Examples:
2163 >>> callable(admin_get_server)
2164 True
2165 >>> admin_get_server.__name__
2166 'admin_get_server'
2167 """
2168 try:
2169 LOGGER.debug(f"User {get_user_email(user)} requested details for server ID {server_id}")
2170 server = await server_service.get_server(db, server_id)
2171 return server.model_dump(by_alias=True)
2172 except ServerNotFoundError as e:
2173 raise HTTPException(status_code=404, detail=str(e))
2174 except Exception as e:
2175 LOGGER.error(f"Error getting server {server_id}: {e}")
2176 raise e
2179@admin_router.post("/servers", response_model=ServerRead)
2180@require_permission("servers.create", allow_admin_bypass=False)
2181async def admin_add_server(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> JSONResponse:
2182 """
2183 Add a new server via the admin UI.
2185 This endpoint processes form data to create a new server entry in the database.
2186 It handles exceptions gracefully and logs any errors that occur during server
2187 registration.
2189 Expects form fields:
2190 - name (required): The name of the server
2191 - description (optional): A description of the server's purpose
2192 - icon (optional): URL or path to the server's icon
2193 - associatedTools (optional, multiple values): Tools associated with this server
2194 - associatedResources (optional, multiple values): Resources associated with this server
2195 - associatedPrompts (optional, multiple values): Prompts associated with this server
2197 Args:
2198 request (Request): FastAPI request containing form data.
2199 db (Session): Database session dependency
2200 user (str): Authenticated user dependency
2202 Returns:
2203 JSONResponse: A JSON response indicating success or failure of the server creation operation.
2205 Examples:
2206 >>> # Test function exists and has correct name
2207 >>> from mcpgateway.admin import admin_add_server
2208 >>> admin_add_server.__name__
2209 'admin_add_server'
2210 >>> # Test it's a coroutine function
2211 >>> import inspect
2212 >>> inspect.iscoroutinefunction(admin_add_server)
2213 True
2214 """
2215 form = await request.form()
2216 # root_path = request.scope.get("root_path", "")
2217 # is_inactive_checked = form.get("is_inactive_checked", "false")
2219 # Parse tags from comma-separated string
2220 tags_str = str(form.get("tags", ""))
2221 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
2223 try:
2224 LOGGER.debug(f"User {get_user_email(user)} is adding a new server with name: {form['name']}")
2225 visibility = str(form.get("visibility", "private"))
2227 # Handle "Select All" for tools
2228 associated_tools_list = form.getlist("associatedTools")
2229 if form.get("selectAllTools") == "true":
2230 # User clicked "Select All" - get all tool IDs from hidden field
2231 all_tool_ids_json = str(form.get("allToolIds", "[]"))
2232 try:
2233 all_tool_ids = orjson.loads(all_tool_ids_json)
2234 associated_tools_list = all_tool_ids
2235 LOGGER.info(f"Select All tools enabled: {len(all_tool_ids)} tools selected")
2236 except orjson.JSONDecodeError:
2237 LOGGER.warning("Failed to parse allToolIds JSON, falling back to checked tools")
2239 # Handle "Select All" for resources
2240 associated_resources_list = form.getlist("associatedResources")
2241 if form.get("selectAllResources") == "true":
2242 all_resource_ids_json = str(form.get("allResourceIds", "[]"))
2243 try:
2244 all_resource_ids = orjson.loads(all_resource_ids_json)
2245 associated_resources_list = all_resource_ids
2246 LOGGER.info(f"Select All resources enabled: {len(all_resource_ids)} resources selected")
2247 except orjson.JSONDecodeError:
2248 LOGGER.warning("Failed to parse allResourceIds JSON, falling back to checked resources")
2250 # Handle "Select All" for prompts
2251 associated_prompts_list = form.getlist("associatedPrompts")
2252 if form.get("selectAllPrompts") == "true":
2253 all_prompt_ids_json = str(form.get("allPromptIds", "[]"))
2254 try:
2255 all_prompt_ids = orjson.loads(all_prompt_ids_json)
2256 associated_prompts_list = all_prompt_ids
2257 LOGGER.info(f"Select All prompts enabled: {len(all_prompt_ids)} prompts selected")
2258 except orjson.JSONDecodeError:
2259 LOGGER.warning("Failed to parse allPromptIds JSON, falling back to checked prompts")
2261 # Handle OAuth 2.0 configuration (RFC 9728)
2262 oauth_enabled = form.get("oauth_enabled") == "on"
2263 oauth_config = None
2264 if oauth_enabled:
2265 authorization_server = str(form.get("oauth_authorization_server", "")).strip()
2266 scopes_str = str(form.get("oauth_scopes", "")).strip()
2267 token_endpoint = str(form.get("oauth_token_endpoint", "")).strip()
2269 if authorization_server:
2270 oauth_config = {"authorization_servers": [authorization_server]}
2271 if scopes_str:
2272 # Convert space-separated scopes to list
2273 oauth_config["scopes_supported"] = scopes_str.split()
2274 if token_endpoint:
2275 oauth_config["token_endpoint"] = token_endpoint
2276 else:
2277 # Invalid or incomplete OAuth configuration; disable OAuth to avoid inconsistent state
2278 LOGGER.warning(
2279 "OAuth was enabled for server '%s' but no authorization server was provided; disabling OAuth for this server.",
2280 form.get("name"),
2281 )
2282 oauth_enabled = False
2283 oauth_config = None
2285 server = ServerCreate(
2286 id=form.get("id") or None,
2287 name=form.get("name"),
2288 description=form.get("description"),
2289 icon=form.get("icon"),
2290 associated_tools=",".join(str(x) for x in associated_tools_list),
2291 associated_resources=",".join(str(x) for x in associated_resources_list),
2292 associated_prompts=",".join(str(x) for x in associated_prompts_list),
2293 tags=tags,
2294 visibility=visibility,
2295 oauth_enabled=oauth_enabled,
2296 oauth_config=oauth_config,
2297 )
2298 except KeyError as e:
2299 # Convert KeyError to ValidationError-like response
2300 return ORJSONResponse(content={"message": f"Missing required field: {e}", "success": False}, status_code=422)
2301 try:
2302 user_email = get_user_email(user)
2303 # Determine personal team for default assignment
2304 team_id_raw = form.get("team_id", None)
2305 team_id = str(team_id_raw) if team_id_raw is not None else None
2307 team_service = TeamManagementService(db)
2308 team_id = await team_service.verify_team_for_user(user_email, team_id)
2310 # Extract metadata for server creation
2311 creation_metadata = MetadataCapture.extract_creation_metadata(request, user)
2313 # Ensure default visibility is private and assign to personal team when available
2314 team_id_cast = typing_cast(Optional[str], team_id)
2315 await server_service.register_server(
2316 db,
2317 server,
2318 created_by=user_email, # Use the consistent user_email
2319 created_from_ip=creation_metadata["created_from_ip"],
2320 created_via=creation_metadata["created_via"],
2321 created_user_agent=creation_metadata["created_user_agent"],
2322 team_id=team_id_cast,
2323 visibility=visibility,
2324 )
2325 return ORJSONResponse(
2326 content={"message": "Server created successfully!", "success": True},
2327 status_code=200,
2328 )
2330 except CoreValidationError as ex:
2331 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=422)
2332 except ServerNameConflictError as ex:
2333 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
2334 except ServerError as ex:
2335 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
2336 # NOTE: Pydantic validation errors subclass ValueError; CoreValidationError must be handled first.
2337 except ValueError as ex:
2338 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400)
2339 except IntegrityError as ex:
2340 return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409)
2341 except Exception as ex:
2342 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
2345@admin_router.post("/servers/{server_id}/edit")
2346@require_permission("servers.update", allow_admin_bypass=False)
2347async def admin_edit_server(
2348 server_id: str,
2349 request: Request,
2350 db: Session = Depends(get_db),
2351 user=Depends(get_current_user_with_permissions),
2352) -> JSONResponse:
2353 """
2354 Edit an existing server via the admin UI.
2356 This endpoint processes form data to update an existing server's properties.
2357 It handles exceptions gracefully and logs any errors that occur during the
2358 update operation.
2360 Expects form fields:
2361 - id (optional): Updated UUID for the server
2362 - name (optional): The updated name of the server
2363 - description (optional): An updated description of the server's purpose
2364 - icon (optional): Updated URL or path to the server's icon
2365 - associatedTools (optional, multiple values): Updated list of tools associated with this server
2366 - associatedResources (optional, multiple values): Updated list of resources associated with this server
2367 - associatedPrompts (optional, multiple values): Updated list of prompts associated with this server
2369 Args:
2370 server_id (str): The ID of the server to edit
2371 request (Request): FastAPI request containing form data
2372 db (Session): Database session dependency
2373 user (str): Authenticated user dependency
2375 Returns:
2376 JSONResponse: A JSON response indicating success or failure of the server update operation.
2378 Examples:
2379 >>> callable(admin_edit_server)
2380 True
2381 >>> admin_edit_server.__name__
2382 'admin_edit_server'
2383 """
2384 form = await request.form()
2386 # Parse tags from comma-separated string
2387 tags_str = str(form.get("tags", ""))
2388 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
2389 try:
2390 LOGGER.debug(f"User {get_user_email(user)} is editing server ID {server_id} with name: {form.get('name')}")
2391 visibility = str(form.get("visibility", "private"))
2392 user_email = get_user_email(user)
2393 team_id_raw = form.get("team_id", None)
2394 team_id = str(team_id_raw) if team_id_raw is not None else None
2396 team_service = TeamManagementService(db)
2397 team_id = await team_service.verify_team_for_user(user_email, team_id)
2399 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
2401 # Handle "Select All" for tools
2402 associated_tools_list = form.getlist("associatedTools")
2403 if form.get("selectAllTools") == "true":
2404 # User clicked "Select All" - get all tool IDs from hidden field
2405 all_tool_ids_json = str(form.get("allToolIds", "[]"))
2406 try:
2407 all_tool_ids = orjson.loads(all_tool_ids_json)
2408 associated_tools_list = all_tool_ids
2409 LOGGER.info(f"Select All tools enabled for edit: {len(all_tool_ids)} tools selected")
2410 except orjson.JSONDecodeError:
2411 LOGGER.warning("Failed to parse allToolIds JSON, falling back to checked tools")
2413 # Handle "Select All" for resources
2414 associated_resources_list = form.getlist("associatedResources")
2415 if form.get("selectAllResources") == "true":
2416 all_resource_ids_json = str(form.get("allResourceIds", "[]"))
2417 try:
2418 all_resource_ids = orjson.loads(all_resource_ids_json)
2419 associated_resources_list = all_resource_ids
2420 LOGGER.info(f"Select All resources enabled for edit: {len(all_resource_ids)} resources selected")
2421 except orjson.JSONDecodeError:
2422 LOGGER.warning("Failed to parse allResourceIds JSON, falling back to checked resources")
2424 # Handle "Select All" for prompts
2425 associated_prompts_list = form.getlist("associatedPrompts")
2426 if form.get("selectAllPrompts") == "true":
2427 all_prompt_ids_json = str(form.get("allPromptIds", "[]"))
2428 try:
2429 all_prompt_ids = orjson.loads(all_prompt_ids_json)
2430 associated_prompts_list = all_prompt_ids
2431 LOGGER.info(f"Select All prompts enabled for edit: {len(all_prompt_ids)} prompts selected")
2432 except orjson.JSONDecodeError:
2433 LOGGER.warning("Failed to parse allPromptIds JSON, falling back to checked prompts")
2435 # Handle OAuth 2.0 configuration (RFC 9728)
2436 oauth_enabled = form.get("oauth_enabled") == "on"
2437 oauth_config = None
2438 if oauth_enabled:
2439 authorization_server = str(form.get("oauth_authorization_server", "")).strip()
2440 scopes_str = str(form.get("oauth_scopes", "")).strip()
2441 token_endpoint = str(form.get("oauth_token_endpoint", "")).strip()
2443 if authorization_server:
2444 oauth_config = {"authorization_servers": [authorization_server]}
2445 if scopes_str:
2446 # Convert space-separated scopes to list
2447 oauth_config["scopes_supported"] = scopes_str.split()
2448 if token_endpoint:
2449 oauth_config["token_endpoint"] = token_endpoint
2450 else:
2451 # Invalid or incomplete OAuth configuration; disable OAuth to avoid inconsistent state
2452 LOGGER.warning(
2453 "OAuth was enabled for server '%s' but no authorization server was provided; disabling OAuth for this server.",
2454 form.get("name"),
2455 )
2456 oauth_enabled = False
2457 oauth_config = None
2459 server = ServerUpdate(
2460 id=form.get("id"),
2461 name=form.get("name"),
2462 description=form.get("description"),
2463 icon=form.get("icon"),
2464 associated_tools=",".join(str(x) for x in associated_tools_list),
2465 associated_resources=",".join(str(x) for x in associated_resources_list),
2466 associated_prompts=",".join(str(x) for x in associated_prompts_list),
2467 tags=tags,
2468 visibility=visibility,
2469 team_id=team_id,
2470 owner_email=user_email,
2471 oauth_enabled=oauth_enabled,
2472 oauth_config=oauth_config,
2473 )
2475 await server_service.update_server(
2476 db,
2477 server_id,
2478 server,
2479 user_email,
2480 modified_by=mod_metadata["modified_by"],
2481 modified_from_ip=mod_metadata["modified_from_ip"],
2482 modified_via=mod_metadata["modified_via"],
2483 modified_user_agent=mod_metadata["modified_user_agent"],
2484 )
2486 return ORJSONResponse(
2487 content={"message": "Server updated successfully!", "success": True},
2488 status_code=200,
2489 )
2490 except (ValidationError, CoreValidationError) as ex:
2491 # Catch both Pydantic and pydantic_core validation errors
2492 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
2493 except ServerNameConflictError as ex:
2494 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
2495 except ServerError as ex:
2496 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
2497 except ValueError as ex:
2498 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400)
2499 except RuntimeError as ex:
2500 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
2501 except IntegrityError as ex:
2502 return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409)
2503 except PermissionError as e:
2504 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
2505 return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403)
2506 except Exception as ex:
2507 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
2510@admin_router.post("/servers/{server_id}/state")
2511@require_permission("servers.update", allow_admin_bypass=False)
2512async def admin_set_server_state(
2513 server_id: str,
2514 request: Request,
2515 db: Session = Depends(get_db),
2516 user=Depends(get_current_user_with_permissions),
2517) -> Response:
2518 """
2519 Set a server's active status via the admin UI.
2521 This endpoint processes a form request to activate or deactivate a server.
2522 It expects a form field 'activate' with value "true" to activate the server
2523 or "false" to deactivate it. The endpoint handles exceptions gracefully and
2524 logs any errors that might occur during the status change operation.
2526 Args:
2527 server_id (str): The ID of the server whose status to set.
2528 request (Request): FastAPI request containing form data with the 'activate' field.
2529 db (Session): Database session dependency.
2530 user (str): Authenticated user dependency.
2532 Returns:
2533 Response: A redirect to the admin dashboard catalog section with a
2534 status code of 303 (See Other).
2536 Examples:
2537 >>> callable(admin_set_server_state)
2538 True
2539 >>> admin_set_server_state.__name__
2540 'admin_set_server_state'
2541 """
2542 form = await request.form()
2543 error_message = None
2544 user_email = get_user_email(user)
2545 LOGGER.debug(f"User {user_email} is setting server ID {server_id} state with activate: {form.get('activate')}")
2546 activate = str(form.get("activate", "true")).lower() == "true"
2547 is_inactive_checked = str(form.get("is_inactive_checked", "false"))
2548 try:
2549 await server_service.set_server_state(db, server_id, activate, user_email=user_email)
2550 except PermissionError as e:
2551 LOGGER.warning(f"Permission denied for user {user_email} setting server {server_id} state: {e}")
2552 error_message = str(e)
2553 except ServerLockConflictError as e:
2554 LOGGER.warning(f"Lock conflict for user {user_email} setting server {server_id} state: {e}")
2555 error_message = "Server is being modified by another request. Please try again."
2556 except Exception as e:
2557 LOGGER.error(f"Error setting server status: {e}")
2558 error_message = "Error setting server status. Please try again."
2560 root_path = request.scope.get("root_path", "")
2562 # Build redirect URL with error message if present
2563 if error_message:
2564 error_param = f"?error={urllib.parse.quote(error_message)}"
2565 if is_inactive_checked.lower() == "true":
2566 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#catalog", status_code=303)
2567 return RedirectResponse(f"{root_path}/admin/{error_param}#catalog", status_code=303)
2569 if is_inactive_checked.lower() == "true":
2570 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#catalog", status_code=303)
2571 return RedirectResponse(f"{root_path}/admin#catalog", status_code=303)
2574@admin_router.post("/servers/{server_id}/delete")
2575@require_permission("servers.delete", allow_admin_bypass=False)
2576async def admin_delete_server(server_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
2577 """
2578 Delete a server via the admin UI.
2580 This endpoint removes a server from the database by its ID. It handles exceptions
2581 gracefully and logs any errors that occur during the deletion process.
2583 Args:
2584 server_id (str): The ID of the server to delete
2585 request (Request): FastAPI request object (not used but required by route signature).
2586 db (Session): Database session dependency
2587 user (str): Authenticated user dependency
2589 Returns:
2590 RedirectResponse: A redirect to the admin dashboard catalog section with a
2591 status code of 303 (See Other)
2593 Examples:
2594 >>> callable(admin_delete_server)
2595 True
2596 >>> admin_delete_server.__name__
2597 'admin_delete_server'
2598 """
2599 form = await request.form()
2600 is_inactive_checked = str(form.get("is_inactive_checked", "false"))
2601 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
2602 error_message = None
2603 try:
2604 user_email = get_user_email(user)
2605 LOGGER.debug(f"User {user_email} is deleting server ID {server_id}")
2606 await server_service.delete_server(db, server_id, user_email=user_email, purge_metrics=purge_metrics)
2607 except PermissionError as e:
2608 LOGGER.warning(f"Permission denied for user {get_user_email(user)} deleting server {server_id}: {e}")
2609 error_message = str(e)
2610 except Exception as e:
2611 LOGGER.error(f"Error deleting server: {e}")
2612 error_message = "Failed to delete server. Please try again."
2614 root_path = request.scope.get("root_path", "")
2616 # Build redirect URL with error message if present
2617 if error_message:
2618 error_param = f"?error={urllib.parse.quote(error_message)}"
2619 if is_inactive_checked.lower() == "true":
2620 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#catalog", status_code=303)
2621 return RedirectResponse(f"{root_path}/admin/{error_param}#catalog", status_code=303)
2623 if is_inactive_checked.lower() == "true":
2624 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#catalog", status_code=303)
2625 return RedirectResponse(f"{root_path}/admin#catalog", status_code=303)
2628@admin_router.get("/resources", response_model=PaginatedResponse)
2629@require_permission("resources.read", allow_admin_bypass=False)
2630async def admin_list_resources(
2631 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
2632 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
2633 include_inactive: bool = False,
2634 db: Session = Depends(get_db),
2635 user=Depends(get_current_user_with_permissions),
2636) -> Dict[str, Any]:
2637 """
2638 List resources for the admin UI with pagination support.
2640 This endpoint retrieves a paginated list of resources from the database, optionally
2641 including those that are inactive. Uses offset-based (page/per_page) pagination.
2643 Args:
2644 page (int): Page number (1-indexed). Default: 1.
2645 per_page (int): Items per page. Default: 50.
2646 include_inactive (bool): Whether to include inactive resources in the results.
2647 db (Session): Database session dependency.
2648 user (str): Authenticated user dependency.
2650 Returns:
2651 Dict with 'data', 'pagination', and 'links' keys containing paginated resources.
2653 Examples:
2654 >>> callable(admin_list_resources)
2655 True
2656 >>> admin_list_resources.__name__
2657 'admin_list_resources'
2658 """
2659 LOGGER.debug(f"User {get_user_email(user)} requested resource list (page={page}, per_page={per_page})")
2660 user_email = get_user_email(user)
2662 # Call resource_service.list_resources with page-based pagination
2663 paginated_result = await resource_service.list_resources(
2664 db=db,
2665 include_inactive=include_inactive,
2666 page=page,
2667 per_page=per_page,
2668 user_email=user_email,
2669 )
2671 # Return standardized paginated response
2672 return {
2673 "data": [resource.model_dump(by_alias=True) for resource in paginated_result["data"]],
2674 "pagination": paginated_result["pagination"].model_dump(),
2675 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
2676 }
2679@admin_router.get("/prompts", response_model=PaginatedResponse)
2680@require_permission("prompts.read", allow_admin_bypass=False)
2681async def admin_list_prompts(
2682 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
2683 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
2684 include_inactive: bool = False,
2685 db: Session = Depends(get_db),
2686 user=Depends(get_current_user_with_permissions),
2687) -> Dict[str, Any]:
2688 """
2689 List prompts for the admin UI with pagination support.
2691 This endpoint retrieves a paginated list of prompts from the database, optionally
2692 including those that are inactive. Uses offset-based (page/per_page) pagination.
2694 Args:
2695 page (int): Page number (1-indexed) for offset pagination.
2696 per_page (int): Number of items per page.
2697 include_inactive (bool): Whether to include inactive prompts in the results.
2698 db (Session): Database session dependency.
2699 user (str): Authenticated user dependency.
2701 Returns:
2702 Dict[str, Any]: A dictionary containing:
2703 - data: List of prompt records formatted with by_alias=True
2704 - pagination: Pagination metadata
2705 - links: Pagination links (optional)
2707 Examples:
2708 >>> callable(admin_list_prompts)
2709 True
2710 >>> admin_list_prompts.__name__
2711 'admin_list_prompts'
2712 """
2713 LOGGER.debug(f"User {get_user_email(user)} requested prompt list (page={page}, per_page={per_page})")
2714 user_email = get_user_email(user)
2716 # Call prompt_service.list_prompts with page-based pagination
2717 paginated_result = await prompt_service.list_prompts(
2718 db=db,
2719 include_inactive=include_inactive,
2720 page=page,
2721 per_page=per_page,
2722 user_email=user_email,
2723 )
2725 # Return standardized paginated response
2726 return {
2727 "data": [prompt.model_dump(by_alias=True) for prompt in paginated_result["data"]],
2728 "pagination": paginated_result["pagination"].model_dump(),
2729 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
2730 }
2733@admin_router.get("/gateways", response_model=PaginatedResponse)
2734@require_permission("gateways.read", allow_admin_bypass=False)
2735async def admin_list_gateways(
2736 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
2737 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
2738 include_inactive: bool = False,
2739 db: Session = Depends(get_db),
2740 user=Depends(get_current_user_with_permissions),
2741) -> Dict[str, Any]:
2742 """
2743 List gateways for the admin UI with pagination support.
2745 This endpoint retrieves a paginated list of gateways from the database, optionally
2746 including those that are inactive. Uses offset-based (page/per_page) pagination.
2748 Args:
2749 page (int): Page number (1-indexed) for offset pagination.
2750 per_page (int): Number of items per page.
2751 include_inactive (bool): Whether to include inactive gateways in the results.
2752 db (Session): Database session dependency.
2753 user (str): Authenticated user dependency.
2755 Returns:
2756 Dict[str, Any]: A dictionary containing:
2757 - data: List of gateway records formatted with by_alias=True
2758 - pagination: Pagination metadata
2759 - links: Pagination links (optional)
2761 Examples:
2762 >>> callable(admin_list_gateways)
2763 True
2764 >>> admin_list_gateways.__name__
2765 'admin_list_gateways'
2766 """
2767 user_email = get_user_email(user)
2768 LOGGER.debug(f"User {user_email} requested gateway list (page={page}, per_page={per_page})")
2770 # Call gateway_service.list_gateways with page-based pagination
2771 paginated_result = await gateway_service.list_gateways(
2772 db=db,
2773 include_inactive=include_inactive,
2774 page=page,
2775 per_page=per_page,
2776 user_email=user_email,
2777 )
2779 # Return standardized paginated response
2780 return {
2781 "data": [gateway.model_dump(by_alias=True) for gateway in paginated_result["data"]],
2782 "pagination": paginated_result["pagination"].model_dump(),
2783 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
2784 }
2787@admin_router.post("/gateways/{gateway_id}/state")
2788@require_permission("gateways.update", allow_admin_bypass=False)
2789async def admin_set_gateway_state(
2790 gateway_id: str,
2791 request: Request,
2792 db: Session = Depends(get_db),
2793 user=Depends(get_current_user_with_permissions),
2794) -> RedirectResponse:
2795 """
2796 Set the active status of a gateway via the admin UI.
2798 This endpoint allows an admin to set the active status of a gateway.
2799 It expects a form field 'activate' with a value of "true" or "false" to
2800 determine the new status of the gateway.
2802 Args:
2803 gateway_id (str): The ID of the gateway to set state for.
2804 request (Request): The FastAPI request object containing form data.
2805 db (Session): The database session dependency.
2806 user (str): The authenticated user dependency.
2808 Returns:
2809 RedirectResponse: A redirect response to the admin dashboard with a
2810 status code of 303 (See Other).
2812 Examples:
2813 >>> callable(admin_set_gateway_state)
2814 True
2815 >>> admin_set_gateway_state.__name__
2816 'admin_set_gateway_state'
2817 """
2818 error_message = None
2819 user_email = get_user_email(user)
2820 LOGGER.debug(f"User {user_email} is setting gateway state for ID {gateway_id}")
2821 form = await request.form()
2822 activate = str(form.get("activate", "true")).lower() == "true"
2823 is_inactive_checked = str(form.get("is_inactive_checked", "false"))
2825 try:
2826 await gateway_service.set_gateway_state(db, gateway_id, activate, user_email=user_email)
2827 except PermissionError as e:
2828 LOGGER.warning(f"Permission denied for user {user_email} setting gateway state {gateway_id}: {e}")
2829 error_message = str(e)
2830 except Exception as e:
2831 LOGGER.error(f"Error setting gateway state: {e}")
2832 error_message = "Failed to set gateway state. Please try again."
2834 root_path = request.scope.get("root_path", "")
2836 # Build redirect URL with error message if present
2837 if error_message:
2838 error_param = f"?error={urllib.parse.quote(error_message)}"
2839 if is_inactive_checked.lower() == "true":
2840 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#gateways", status_code=303)
2841 return RedirectResponse(f"{root_path}/admin/{error_param}#gateways", status_code=303)
2843 if is_inactive_checked.lower() == "true":
2844 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#gateways", status_code=303)
2845 return RedirectResponse(f"{root_path}/admin#gateways", status_code=303)
2848@admin_router.get("/", name="admin_home", response_class=HTMLResponse)
2849@require_permission("admin.dashboard", allow_admin_bypass=False)
2850async def admin_ui(
2851 request: Request,
2852 team_id: Optional[str] = Depends(_validated_team_id_param),
2853 include_inactive: bool = False,
2854 db: Session = Depends(get_db),
2855 user=Depends(get_current_user_with_permissions),
2856 _jwt_token: str = Depends(get_jwt_token),
2857) -> Any:
2858 """
2859 Render the admin dashboard HTML page.
2861 This endpoint serves as the main entry point to the admin UI. It fetches data for
2862 servers, tools, resources, prompts, gateways, and roots from their respective
2863 services, then renders the admin dashboard template with this data.
2865 Supports optional `team_id` query param to scope the returned data to a team.
2866 If `team_id` is provided and email-based team management is enabled, we
2867 validate the user is a member of that team. We attempt to pass team_id into
2868 service listing functions (preferred). If the service API does not accept a
2869 team_id parameter we fall back to post-filtering the returned items.
2871 The endpoint also sets a JWT token as a cookie for authentication in subsequent
2872 requests. This token is HTTP-only for security reasons.
2874 Args:
2875 request (Request): FastAPI request object.
2876 team_id (Optional[str]): Optional team ID to filter data by team.
2877 include_inactive (bool): Whether to include inactive items in all listings.
2878 db (Session): Database session dependency.
2879 user (dict): Authenticated user context with permissions.
2881 Returns:
2882 Any: Rendered HTML template for the admin dashboard.
2884 Examples:
2885 >>> callable(admin_ui)
2886 True
2887 >>> admin_ui.__name__
2888 'admin_ui'
2889 """
2890 LOGGER.debug(f"User {get_user_email(user)} accessed the admin UI (team_id={team_id})")
2891 user_email = get_user_email(user)
2892 ui_visibility_config = get_ui_visibility_config(request)
2893 hidden_sections = set(ui_visibility_config["hidden_sections"])
2894 hidden_header_items = set(ui_visibility_config["hidden_header_items"])
2896 # --------------------------------------------------------------------------------
2897 # Load user teams so we can validate team_id
2898 # --------------------------------------------------------------------------------
2899 user_teams = []
2900 team_service = None
2901 sections_requiring_user_teams = {
2902 "teams",
2903 "tokens",
2904 "users",
2905 "tools",
2906 "servers",
2907 "resources",
2908 "prompts",
2909 "gateways",
2910 "agents",
2911 }
2912 should_load_user_teams = getattr(settings, "email_auth_enabled", False) and (
2913 team_id is not None or "team_selector" not in hidden_header_items or bool(sections_requiring_user_teams - hidden_sections)
2914 )
2915 if should_load_user_teams:
2916 try:
2917 team_service = TeamManagementService(db)
2918 if user_email and "@" in user_email:
2919 raw_teams = await team_service.get_user_teams(user_email)
2921 # Batch fetch all data in 2 queries instead of 2N queries (N+1 elimination)
2922 team_ids = [str(team.id) for team in raw_teams]
2923 member_counts = await team_service.get_member_counts_batch_cached(team_ids)
2924 user_roles = team_service.get_user_roles_batch(user_email, team_ids)
2926 user_teams = []
2927 for team in raw_teams:
2928 try:
2929 current_team_id = str(team.id) if team.id else ""
2930 team_dict = {
2931 "id": current_team_id,
2932 "name": str(team.name) if team.name else "",
2933 "type": str(getattr(team, "type", "organization")),
2934 "is_personal": bool(getattr(team, "is_personal", False)),
2935 "member_count": member_counts.get(current_team_id, 0),
2936 "role": user_roles.get(current_team_id) or "member",
2937 }
2938 user_teams.append(team_dict)
2939 except Exception as team_error:
2940 LOGGER.warning(f"Failed to serialize team {getattr(team, 'id', 'unknown')}: {team_error}")
2941 continue
2942 except Exception as e:
2943 LOGGER.warning(f"Failed to load user teams: {e}")
2944 user_teams = []
2946 # --------------------------------------------------------------------------------
2947 # Validate team_id if provided (only when email-based teams are enabled)
2948 # If invalid, we currently *ignore* it and fall back to default behavior.
2949 # Optionally you can raise HTTPException(403) if you prefer strict rejection.
2950 # --------------------------------------------------------------------------------
2951 selected_team_id = team_id
2952 user_email = get_user_email(user)
2953 if team_id and getattr(settings, "email_auth_enabled", False):
2954 # If team list failed to load for some reason, be conservative and drop selection
2955 if not user_teams:
2956 LOGGER.warning("team_id requested but user_teams not available; ignoring team filter")
2957 selected_team_id = None
2958 else:
2959 valid_team_ids = {t["id"] for t in user_teams if t.get("id")}
2960 if str(team_id) not in valid_team_ids:
2961 LOGGER.warning("Requested team_id is not in user's teams; ignoring team filter (team_id=%s)", team_id)
2962 selected_team_id = None
2964 # --------------------------------------------------------------------------------
2965 # Helper: attempt to call a listing function with team_id if it supports it.
2966 # If the method signature doesn't accept team_id, fall back to calling it without
2967 # and then (optionally) filter the returned results.
2968 # --------------------------------------------------------------------------------
2969 async def _call_list_with_team_support(method, *args, **kwargs):
2970 """
2971 Attempt to call a method with an optional `team_id` parameter.
2973 This function tries to call the given asynchronous `method` with all provided
2974 arguments and an additional `team_id=selected_team_id`, assuming `selected_team_id`
2975 is defined and not None. If the method does not accept a `team_id` keyword argument
2976 (raises TypeError), the function retries the call without it.
2978 This is useful in scenarios where some service methods optionally support team
2979 scoping via a `team_id` parameter, but not all do.
2981 Args:
2982 method (Callable): The async function to be called.
2983 *args: Positional arguments to pass to the method.
2984 **kwargs: Keyword arguments to pass to the method.
2986 Returns:
2987 Any: The result of the awaited method call, typically a list of model instances.
2989 Raises:
2990 Any exception raised by the method itself, except TypeError when `team_id` is unsupported.
2993 Doctest:
2994 >>> async def sample_method(a, b):
2995 ... return [a, b]
2996 >>> async def sample_method_with_team(a, b, team_id=None):
2997 ... return [a, b, team_id]
2998 >>> selected_team_id = 42
2999 >>> import asyncio
3000 >>> asyncio.run(_call_list_with_team_support(sample_method_with_team, 1, 2))
3001 [1, 2, 42]
3002 >>> asyncio.run(_call_list_with_team_support(sample_method, 1, 2))
3003 [1, 2]
3005 Notes:
3006 - This function depends on a global `selected_team_id` variable.
3007 - If `selected_team_id` is None, the method is called without `team_id`.
3008 """
3009 if selected_team_id is None:
3010 return await method(*args, **kwargs)
3012 try:
3013 # Preferred: pass team_id to the service method if it accepts it
3014 return await method(*args, team_id=selected_team_id, **kwargs)
3015 except TypeError:
3016 # The method doesn't accept team_id -> fall back to original API
3017 LOGGER.debug("Service method %s does not accept team_id; falling back and will post-filter", getattr(method, "__name__", str(method)))
3018 return await method(*args, **kwargs)
3020 # Small utility to check if a returned model or dict matches the selected_team_id.
3021 def _matches_selected_team(item, tid: str) -> bool:
3022 """
3023 Determine whether the given item is associated with the specified team ID.
3025 This function attempts to determine if the input `item` (which may be a Pydantic model,
3026 an object with attributes, or a dictionary) is associated with the given team ID (`tid`).
3027 It checks several common attribute names (e.g., `team_id`, `team_ids`, `teams`) to see
3028 if any of them match the provided team ID. These fields may contain either a single ID
3029 or a list of IDs.
3031 If `tid` is falsy (e.g., empty string), the function returns True.
3033 Args:
3034 item: An object or dictionary that may contain team identification fields.
3035 tid (str): The team ID to match.
3037 Returns:
3038 bool: True if the item is associated with the specified team ID, otherwise False.
3040 Examples:
3041 >>> class Obj:
3042 ... team_id = 'abc123'
3043 >>> _matches_selected_team(Obj(), 'abc123')
3044 True
3046 >>> class Obj:
3047 ... team_ids = ['abc123', 'def456']
3048 >>> _matches_selected_team(Obj(), 'def456')
3049 True
3051 >>> _matches_selected_team({'teamId': 'xyz789'}, 'xyz789')
3052 True
3054 >>> _matches_selected_team({'teamIds': ['123', '456']}, '789')
3055 False
3057 >>> _matches_selected_team({'teams': ['t1', 't2']}, 't1')
3058 True
3060 >>> _matches_selected_team(None, 'abc')
3061 False
3062 """
3063 # If an item is explicitly public, it should be visible to any team
3064 try:
3065 vis = getattr(item, "visibility", None)
3066 if vis is None and isinstance(item, dict):
3067 vis = item.get("visibility")
3068 if isinstance(vis, str) and vis.lower() == "public":
3069 return True
3070 except Exception as exc: # pragma: no cover - defensive logging for unexpected types
3071 LOGGER.debug(
3072 "Error checking visibility on item (type=%s): %s",
3073 type(item),
3074 exc,
3075 exc_info=True,
3076 )
3077 # item may be a pydantic model or dict-like
3078 # check common fields for team membership
3079 candidates = []
3080 try:
3081 # If it's an object with attributes
3082 candidates.extend(
3083 [
3084 getattr(item, "team_id", None),
3085 getattr(item, "teamId", None),
3086 getattr(item, "team_ids", None),
3087 getattr(item, "teamIds", None),
3088 getattr(item, "teams", None),
3089 ]
3090 )
3091 except Exception:
3092 pass # nosec B110 - Intentionally ignore errors when extracting team IDs from objects
3093 try:
3094 # If it's a dict-like model_dump output (we'll check keys later after model_dump)
3095 if isinstance(item, dict):
3096 candidates.extend(
3097 [
3098 item.get("team_id"),
3099 item.get("teamId"),
3100 item.get("team_ids"),
3101 item.get("teamIds"),
3102 item.get("teams"),
3103 ]
3104 )
3105 except Exception:
3106 pass # nosec B110 - Intentionally ignore errors when extracting team IDs from dict objects
3108 for c in candidates:
3109 if c is None:
3110 continue
3111 # Some fields may be single id or list of ids
3112 if isinstance(c, (list, tuple, set)):
3113 if str(tid) in [str(x) for x in c]:
3114 return True
3115 else:
3116 if str(c) == str(tid):
3117 return True
3118 return False
3120 # --------------------------------------------------------------------------------
3121 # Load each resource list using the safe _call_list_with_team_support helper.
3122 # For each returned list, try to produce consistent "model_dump(by_alias=True)" dicts,
3123 # applying server-side filtering as a fallback if the service didn't accept team_id.
3124 # --------------------------------------------------------------------------------
3125 raw_tools = []
3126 if "tools" not in hidden_sections:
3127 try:
3128 raw_tools = await _call_list_with_team_support(tool_service.list_tools, db, include_inactive=include_inactive, user_email=user_email, limit=0)
3129 if isinstance(raw_tools, tuple):
3130 raw_tools = raw_tools[0]
3131 except Exception as e:
3132 LOGGER.exception("Failed to load tools for user: %s", e)
3134 raw_servers = []
3135 if "servers" not in hidden_sections:
3136 try:
3137 raw_servers = await _call_list_with_team_support(server_service.list_servers, db, include_inactive=include_inactive, user_email=user_email, limit=0)
3138 # Handle tuple return (list, cursor)
3139 if isinstance(raw_servers, tuple):
3140 raw_servers = raw_servers[0]
3141 except Exception as e:
3142 LOGGER.exception("Failed to load servers for user: %s", e)
3144 raw_resources = []
3145 if "resources" not in hidden_sections:
3146 try:
3147 raw_resources = await _call_list_with_team_support(resource_service.list_resources, db, include_inactive=include_inactive, user_email=user_email, limit=0)
3148 if isinstance(raw_resources, tuple):
3149 raw_resources = raw_resources[0]
3150 except Exception as e:
3151 LOGGER.exception("Failed to load resources for user: %s", e)
3153 raw_prompts = []
3154 if "prompts" not in hidden_sections:
3155 try:
3156 raw_prompts = await _call_list_with_team_support(prompt_service.list_prompts, db, include_inactive=include_inactive, user_email=user_email, limit=0)
3157 # Handle tuple return (list, cursor)
3158 if isinstance(raw_prompts, tuple):
3159 raw_prompts = raw_prompts[0]
3160 except Exception as e:
3161 LOGGER.exception("Failed to load prompts for user: %s", e)
3163 gateways_raw = []
3164 if "gateways" not in hidden_sections:
3165 try:
3166 gateways_raw = await _call_list_with_team_support(gateway_service.list_gateways, db, include_inactive=include_inactive, user_email=user_email, limit=0)
3167 # Handle tuple return (list, cursor)
3168 if isinstance(gateways_raw, tuple):
3169 gateways_raw = gateways_raw[0]
3170 except Exception as e:
3171 LOGGER.exception("Failed to load gateways: %s", e)
3173 # Convert models to dicts and filter as needed
3174 def _to_dict_and_filter(raw_list):
3175 """
3176 Convert a list of items (Pydantic models, dicts, or similar) to dictionaries and filter them
3177 based on a globally defined `selected_team_id`.
3179 For each item:
3180 - Try to convert it to a dictionary via `.model_dump(by_alias=True)` (if it's a Pydantic model),
3181 or keep it as-is if it's already a dictionary.
3182 - If the conversion fails, try to coerce the item to a dictionary via `dict(item)`.
3183 - If `selected_team_id` is set, include only items that match it via `_matches_selected_team`.
3185 Args:
3186 raw_list (list): A list of Pydantic models, dictionaries, or similar objects.
3188 Returns:
3189 list: A filtered list of dictionaries.
3191 Examples:
3192 >>> global selected_team_id
3193 >>> selected_team_id = 'team123'
3194 >>> class Model:
3195 ... def __init__(self, team_id): self.team_id = team_id
3196 ... def model_dump(self, by_alias=False): return {'team_id': self.team_id}
3197 >>> items = [Model('team123'), Model('team999')]
3198 >>> _to_dict_and_filter(items)
3199 [{'team_id': 'team123'}]
3201 >>> selected_team_id = None
3202 >>> _to_dict_and_filter([{'team_id': 'any_team'}])
3203 [{'team_id': 'any_team'}]
3205 >>> selected_team_id = 't1'
3206 >>> _to_dict_and_filter([{'team_ids': ['t1', 't2']}, {'team_ids': ['t3']}])
3207 [{'team_ids': ['t1', 't2']}]
3208 """
3209 out = []
3210 for item in raw_list or []:
3211 try:
3212 dumped = item.model_dump(by_alias=True) if hasattr(item, "model_dump") else (item if isinstance(item, dict) else None)
3213 except Exception:
3214 # if dumping failed, try to coerce to dict
3215 try:
3216 dumped = dict(item) if hasattr(item, "__iter__") else None
3217 except Exception:
3218 dumped = None
3219 if dumped is None:
3220 continue
3222 # If we passed team_id to service, server-side filtering applied.
3223 # Otherwise, filter by common team-aware fields if selected_team_id is set.
3224 if selected_team_id:
3225 if _matches_selected_team(item, selected_team_id) or _matches_selected_team(dumped, selected_team_id):
3226 out.append(dumped)
3227 else:
3228 # skip items that don't match the selected team
3229 continue
3230 else:
3231 out.append(dumped)
3232 return out
3234 tools = list(sorted(_to_dict_and_filter(raw_tools), key=lambda t: ((t.get("url") or "").lower(), (t.get("original_name") or "").lower())))
3235 servers = _to_dict_and_filter(raw_servers)
3236 resources = _to_dict_and_filter(raw_resources) # pylint: disable=unnecessary-comprehension
3237 prompts = _to_dict_and_filter(raw_prompts)
3238 gateways = [g.model_dump(by_alias=True) if hasattr(g, "model_dump") else (g if isinstance(g, dict) else {}) for g in (gateways_raw or [])]
3239 # If gateways need team filtering as dicts too, apply _to_dict_and_filter similarly:
3240 gateways = _to_dict_and_filter(gateways_raw) if isinstance(gateways_raw, (list, tuple)) else gateways
3242 # roots
3243 roots = [root.model_dump(by_alias=True) for root in await root_service.list_roots()]
3245 # Load A2A agents if enabled
3246 a2a_agents = []
3247 if "agents" not in hidden_sections and a2a_service and settings.mcpgateway_a2a_enabled:
3248 a2a_agents_raw = await a2a_service.list_agents_for_user(
3249 db,
3250 user_info=user_email,
3251 include_inactive=include_inactive,
3252 )
3253 a2a_agents = [agent.model_dump(by_alias=True) for agent in a2a_agents_raw]
3254 a2a_agents = _to_dict_and_filter(a2a_agents) if isinstance(a2a_agents, (list, tuple)) else a2a_agents
3256 # Load gRPC services if enabled and available
3257 grpc_services = []
3258 try:
3259 if "agents" not in hidden_sections and GRPC_AVAILABLE and grpc_service_mgr and settings.mcpgateway_grpc_enabled:
3260 grpc_services_raw = await grpc_service_mgr.list_services(
3261 db,
3262 include_inactive=include_inactive,
3263 user_email=user_email,
3264 team_id=selected_team_id,
3265 )
3266 grpc_services = [service.model_dump(by_alias=True) for service in grpc_services_raw]
3267 grpc_services = _to_dict_and_filter(grpc_services) if isinstance(grpc_services, (list, tuple)) else grpc_services
3268 except Exception as e:
3269 LOGGER.exception("Failed to load gRPC services: %s", e)
3270 grpc_services = []
3272 # Template variables and context: include selected_team_id so the template and frontend can read it
3273 root_path = settings.app_root_path
3274 max_name_length = settings.validation_max_name_length
3276 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
3277 db.commit()
3279 response = request.app.state.templates.TemplateResponse(
3280 request,
3281 "admin.html",
3282 {
3283 "request": request,
3284 "servers": servers,
3285 "tools": tools,
3286 "resources": resources,
3287 "prompts": prompts,
3288 "gateways": gateways,
3289 "a2a_agents": a2a_agents,
3290 "grpc_services": grpc_services,
3291 "roots": roots,
3292 "include_inactive": include_inactive,
3293 "root_path": root_path,
3294 "max_name_length": max_name_length,
3295 "gateway_tool_name_separator": settings.gateway_tool_name_separator,
3296 "bulk_import_max_tools": settings.mcpgateway_bulk_import_max_tools,
3297 "a2a_enabled": settings.mcpgateway_a2a_enabled,
3298 "grpc_enabled": GRPC_AVAILABLE and settings.mcpgateway_grpc_enabled,
3299 "catalog_enabled": settings.mcpgateway_catalog_enabled,
3300 "llmchat_enabled": getattr(settings, "llmchat_enabled", False),
3301 "toolops_enabled": getattr(settings, "toolops_enabled", False),
3302 "observability_enabled": getattr(settings, "observability_enabled", False),
3303 "performance_enabled": getattr(settings, "mcpgateway_performance_tracking", False),
3304 "current_user": get_user_email(user),
3305 "email_auth_enabled": getattr(settings, "email_auth_enabled", False),
3306 "is_admin": bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)),
3307 "user_teams": user_teams,
3308 "mcpgateway_ui_tool_test_timeout": settings.mcpgateway_ui_tool_test_timeout,
3309 "selected_team_id": selected_team_id,
3310 "ui_airgapped": settings.mcpgateway_ui_airgapped,
3311 "ui_hidden_sections": ui_visibility_config["hidden_sections"],
3312 "ui_hidden_header_items": ui_visibility_config["hidden_header_items"],
3313 "ui_hidden_tabs": ui_visibility_config["hidden_tabs"],
3314 # Password policy flags for frontend templates
3315 "password_min_length": getattr(settings, "password_min_length", 8),
3316 "password_require_uppercase": getattr(settings, "password_require_uppercase", False),
3317 "password_require_lowercase": getattr(settings, "password_require_lowercase", False),
3318 "password_require_numbers": getattr(settings, "password_require_numbers", False),
3319 "password_require_special": getattr(settings, "password_require_special", False),
3320 # Token policy flags
3321 "require_token_expiration": getattr(settings, "require_token_expiration", True),
3322 },
3323 )
3325 # Set JWT token cookie for HTMX requests if email auth is enabled
3326 if getattr(settings, "email_auth_enabled", False):
3327 try:
3328 # JWT library is imported at top level as jwt
3330 # Determine the admin user email
3331 admin_email = get_user_email(user)
3332 is_admin_flag = bool(user.get("is_admin") if isinstance(user, dict) else True)
3333 full_name = getattr(settings, "platform_admin_full_name", "Platform User")
3334 if isinstance(user, dict):
3335 full_name = user.get("full_name") or full_name
3336 else:
3337 full_name = getattr(user, "full_name", full_name) or full_name
3339 # Preserve auth provider across admin UI token refreshes so logout behavior
3340 # can reliably detect SSO sessions (e.g., Keycloak) later.
3341 auth_provider = "local"
3342 if isinstance(user, dict):
3343 provider_from_user = user.get("auth_provider")
3344 if isinstance(provider_from_user, str) and provider_from_user.strip():
3345 auth_provider = provider_from_user.strip()
3346 else:
3347 provider_from_user = getattr(user, "auth_provider", None)
3348 if isinstance(provider_from_user, str) and provider_from_user.strip():
3349 auth_provider = provider_from_user.strip()
3351 # get_current_user_with_permissions may not include auth_provider in its dict.
3352 # Fall back to the current jwt_token cookie payload before refreshing it.
3353 if auth_provider == "local":
3354 jwt_cookie = request.cookies.get("jwt_token")
3355 if isinstance(jwt_cookie, str) and jwt_cookie:
3356 try:
3357 existing_payload = await verify_jwt_token_cached(jwt_cookie, request)
3358 existing_user = existing_payload.get("user")
3359 provider_from_token = existing_user.get("auth_provider") if isinstance(existing_user, dict) else None
3360 if not provider_from_token:
3361 provider_from_token = existing_payload.get("auth_provider")
3362 if isinstance(provider_from_token, str) and provider_from_token.strip():
3363 auth_provider = provider_from_token.strip()
3364 except Exception as provider_error: # nosec B110 - best-effort provider preservation
3365 LOGGER.warning("Could not resolve auth_provider from existing JWT cookie; SSO logout may not function correctly: %s", provider_error)
3366 if settings.sso_keycloak_enabled:
3367 auth_provider = "keycloak"
3369 # Generate a lightweight session JWT token
3370 now = datetime.now(timezone.utc)
3371 payload = {
3372 "sub": admin_email,
3373 "iss": settings.jwt_issuer,
3374 "aud": settings.jwt_audience,
3375 "iat": int(now.timestamp()),
3376 "exp": int((now + timedelta(minutes=settings.token_expiry)).timestamp()),
3377 "jti": str(uuid.uuid4()),
3378 "auth_provider": auth_provider,
3379 "user": {"email": admin_email, "full_name": full_name, "is_admin": is_admin_flag, "auth_provider": auth_provider},
3380 "token_use": "session", # nosec B105 - token type marker, not a password
3381 "scopes": {"server_id": None, "permissions": ["*"] if is_admin_flag else [], "ip_restrictions": [], "time_restrictions": {}},
3382 }
3384 # Generate token using centralized token creation
3385 token = await create_jwt_token(payload)
3387 # Set HTTP-only cookie using centralized security cookie utility
3388 set_auth_cookie(response, token, remember_me=False)
3389 LOGGER.debug(f"Set session JWT token cookie for user: {admin_email}")
3390 except Exception as e:
3391 LOGGER.warning(f"Failed to set JWT token cookie for user {user}: {e}")
3393 cookie_action = ui_visibility_config.get("cookie_action")
3394 if cookie_action:
3395 scope_root_path = request.scope.get("root_path", "") or ""
3396 ui_cookie_path = f"{scope_root_path}/admin" if scope_root_path else "/admin"
3397 use_secure = (settings.environment == "production") or settings.secure_cookies
3398 samesite = settings.cookie_samesite
3399 if cookie_action == "set":
3400 response.set_cookie(
3401 key=UI_HIDE_SECTIONS_COOKIE_NAME,
3402 value=ui_visibility_config.get("cookie_value", ""),
3403 max_age=UI_HIDE_SECTIONS_COOKIE_MAX_AGE,
3404 path=ui_cookie_path,
3405 httponly=True,
3406 secure=use_secure,
3407 samesite=samesite,
3408 )
3409 elif cookie_action == "delete":
3410 response.delete_cookie(
3411 key=UI_HIDE_SECTIONS_COOKIE_NAME,
3412 path=ui_cookie_path,
3413 secure=use_secure,
3414 httponly=True,
3415 samesite=samesite,
3416 )
3418 return response
3421@admin_router.get("/login")
3422async def admin_login_page(request: Request) -> Response:
3423 """
3424 Render the admin login page.
3426 This endpoint serves the login form for email-based authentication.
3427 If email auth is disabled, redirects to the main admin page.
3429 Args:
3430 request (Request): FastAPI request object.
3432 Returns:
3433 Response: Rendered HTML or redirect response.
3435 Examples:
3436 >>> from fastapi import Request
3437 >>> from fastapi.responses import HTMLResponse
3438 >>> from unittest.mock import MagicMock
3439 >>>
3440 >>> # Mock request
3441 >>> mock_request = MagicMock(spec=Request)
3442 >>> mock_request.scope = {"root_path": "/test"}
3443 >>> mock_request.app.state.templates = MagicMock()
3444 >>> mock_response = HTMLResponse("<html>Login</html>")
3445 >>> mock_request.app.state.templates.TemplateResponse.return_value = mock_response
3446 >>>
3447 >>> import asyncio
3448 >>> async def test_login_page():
3449 ... response = await admin_login_page(mock_request)
3450 ... return isinstance(response, HTMLResponse)
3451 >>>
3452 >>> asyncio.run(test_login_page())
3453 True
3454 """
3455 # Check if email auth is enabled
3456 if not getattr(settings, "email_auth_enabled", False):
3457 root_path = request.scope.get("root_path", "")
3458 return RedirectResponse(url=f"{root_path}/admin", status_code=303)
3460 root_path = settings.app_root_path
3462 # Only show secure cookie warning if there's a login error AND problematic config
3463 secure_cookie_warning = None
3464 if settings.secure_cookies and settings.environment == "development":
3465 secure_cookie_warning = "Serving over HTTP with secure cookies enabled. If you have login issues, try disabling secure cookies in your configuration."
3467 # Preserve email from failed login attempt
3468 prefill_email = request.query_params.get("email", "")
3470 # Use external template file
3471 return request.app.state.templates.TemplateResponse(
3472 request,
3473 "login.html",
3474 {
3475 "request": request,
3476 "root_path": root_path,
3477 "secure_cookie_warning": secure_cookie_warning,
3478 "ui_airgapped": settings.mcpgateway_ui_airgapped,
3479 "prefill_email": prefill_email,
3480 "password_reset_enabled": getattr(settings, "password_reset_enabled", True),
3481 },
3482 )
3485@admin_router.post("/login")
3486async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
3487 """
3488 Handle admin login form submission.
3490 This endpoint processes the email/password login form, authenticates the user,
3491 sets the JWT cookie, and redirects to the admin panel or back to login with error.
3493 Args:
3494 request (Request): FastAPI request object.
3495 db (Session): Database session dependency.
3497 Returns:
3498 RedirectResponse: Redirect to admin panel on success or login page on failure.
3500 Examples:
3501 >>> from fastapi import Request
3502 >>> from fastapi.responses import RedirectResponse
3503 >>> from unittest.mock import MagicMock, AsyncMock
3504 >>>
3505 >>> # Mock request with form data
3506 >>> mock_request = MagicMock(spec=Request)
3507 >>> mock_request.scope = {"root_path": "/test"}
3508 >>> mock_form = {"email": "admin@example.com", "password": "changeme"}
3509 >>> mock_request.form = AsyncMock(return_value=mock_form)
3510 >>>
3511 >>> mock_db = MagicMock()
3512 >>>
3513 >>> import asyncio
3514 >>> async def test_login_handler():
3515 ... try:
3516 ... response = await admin_login_handler(mock_request, mock_db)
3517 ... return isinstance(response, RedirectResponse)
3518 ... except Exception:
3519 ... return True # Expected due to mocked dependencies
3520 >>>
3521 >>> asyncio.run(test_login_handler())
3522 True
3523 """
3524 if not getattr(settings, "email_auth_enabled", False):
3525 root_path = request.scope.get("root_path", "")
3526 return RedirectResponse(url=f"{root_path}/admin", status_code=303)
3528 try:
3529 form = await request.form()
3530 email_val = form.get("email")
3531 password_val = form.get("password")
3532 email = email_val if isinstance(email_val, str) else None
3533 password = password_val if isinstance(password_val, str) else None
3535 if not email or not password:
3536 root_path = request.scope.get("root_path", "")
3537 params = "error=missing_fields"
3538 if email:
3539 params += f"&email={urllib.parse.quote(email)}"
3540 return RedirectResponse(url=f"{root_path}/admin/login?{params}", status_code=303)
3542 # Authenticate using the email auth service
3543 auth_service = EmailAuthService(db)
3545 try:
3546 # Authenticate user
3547 LOGGER.debug(f"Attempting authentication for {email}")
3548 user = await auth_service.authenticate_user(email, password)
3549 LOGGER.debug(f"Authentication result: {user}")
3551 if not user:
3552 LOGGER.warning(f"Authentication failed for {email} - user is None")
3553 root_path = request.scope.get("root_path", "")
3554 return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303)
3556 # Password change enforcement respects master switch and toggles
3557 needs_password_change = False
3559 if settings.password_change_enforcement_enabled:
3560 # If flag is set on the user, always honor it (flag is cleared when password is changed)
3561 if getattr(user, "password_change_required", False):
3562 needs_password_change = True
3563 LOGGER.debug("User %s has password_change_required flag set", email)
3565 # Enforce expiry-based password change if configured and not already required
3566 if not needs_password_change:
3567 try:
3568 pwd_changed = getattr(user, "password_changed_at", None)
3569 if pwd_changed:
3570 age_days = (utc_now() - pwd_changed).days
3571 max_age = getattr(settings, "password_max_age_days", 90)
3572 if age_days >= max_age:
3573 needs_password_change = True
3574 LOGGER.debug("User %s password expired (%s days >= %s)", email, age_days, max_age)
3575 except Exception as exc:
3576 LOGGER.debug("Failed to evaluate password age for %s: %s", email, exc)
3578 # Detect default password on login if enabled
3579 if getattr(settings, "detect_default_password_on_login", True):
3580 password_service = Argon2PasswordService()
3581 is_using_default_password = await password_service.verify_password_async(settings.default_user_password.get_secret_value(), user.password_hash) # nosec B105
3582 if is_using_default_password:
3583 if getattr(settings, "require_password_change_for_default_password", True):
3584 user.password_change_required = True
3585 needs_password_change = True
3586 try:
3587 db.commit()
3588 except Exception as exc: # log commit failures
3589 LOGGER.warning("Failed to commit password_change_required flag for %s: %s", email, exc)
3590 else:
3591 LOGGER.info("User %s is using default password but enforcement is disabled", email)
3593 if needs_password_change:
3594 LOGGER.info(f"User {email} requires password change - redirecting to change password page")
3596 # Create temporary JWT token for password change process
3597 token, _ = await create_access_token(user)
3599 # Create redirect response to password change page
3600 root_path = request.scope.get("root_path", "")
3601 response = RedirectResponse(url=f"{root_path}/admin/change-password-required", status_code=303)
3603 # Set JWT token as secure cookie for the password change process
3604 try:
3605 set_auth_cookie(response, token, remember_me=False)
3606 except CookieTooLargeError:
3607 root_path = request.scope.get("root_path", "")
3608 return RedirectResponse(
3609 url=f"{root_path}/admin/login?error=token_too_large&email={urllib.parse.quote(email)}",
3610 status_code=303,
3611 )
3613 return response
3615 # Create JWT token with proper audience and issuer claims
3616 token, _ = await create_access_token(user) # expires_seconds not needed here
3618 # Create redirect response
3619 root_path = request.scope.get("root_path", "")
3620 response = RedirectResponse(url=f"{root_path}/admin", status_code=303)
3622 # Set JWT token as secure cookie
3623 try:
3624 set_auth_cookie(response, token, remember_me=False)
3625 except CookieTooLargeError:
3626 return RedirectResponse(
3627 url=f"{root_path}/admin/login?error=token_too_large&email={urllib.parse.quote(email)}",
3628 status_code=303,
3629 )
3631 LOGGER.info(f"Admin user {email} logged in successfully")
3632 return response
3634 except Exception as e:
3635 LOGGER.warning(f"Login failed for {email}: {e}")
3637 if settings.secure_cookies and settings.environment == "development":
3638 LOGGER.warning("Login failed - set SECURE_COOKIES to false in config for HTTP development")
3640 root_path = request.scope.get("root_path", "")
3641 return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303)
3643 except Exception as e:
3644 LOGGER.error(f"Login handler error: {e}")
3645 root_path = request.scope.get("root_path", "")
3646 return RedirectResponse(url=f"{root_path}/admin/login?error=server_error", status_code=303)
3649@admin_router.get("/forgot-password")
3650async def admin_forgot_password_page(request: Request) -> Response:
3651 """Render forgot-password page.
3653 Args:
3654 request: Incoming HTTP request.
3656 Returns:
3657 Response: Forgot-password page response.
3658 """
3659 root_path = settings.app_root_path
3660 if not getattr(settings, "email_auth_enabled", False):
3661 return RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
3662 return request.app.state.templates.TemplateResponse(
3663 request,
3664 "forgot-password.html",
3665 {
3666 "request": request,
3667 "root_path": root_path,
3668 "password_reset_enabled": getattr(settings, "password_reset_enabled", True),
3669 "ui_airgapped": settings.mcpgateway_ui_airgapped,
3670 },
3671 )
3674@admin_router.post("/forgot-password")
3675async def admin_forgot_password_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
3676 """Handle forgot-password form submission.
3678 Args:
3679 request: Incoming HTTP request with form data.
3680 db: Database session dependency.
3682 Returns:
3683 RedirectResponse: Redirect to login or forgot-password page with status.
3684 """
3685 root_path = request.scope.get("root_path", "")
3686 if not getattr(settings, "email_auth_enabled", False):
3687 return RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
3688 if not getattr(settings, "password_reset_enabled", True):
3689 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303)
3691 try:
3692 form = await request.form()
3693 email_val = form.get("email")
3694 email = str(email_val).strip() if email_val else ""
3695 if not email:
3696 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=missing_email", status_code=303)
3698 auth_service = EmailAuthService(db)
3699 result = await auth_service.request_password_reset(email=email, ip_address=get_client_ip(request), user_agent=get_user_agent(request))
3700 if result.rate_limited:
3701 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=rate_limited", status_code=303)
3702 return RedirectResponse(url=f"{root_path}/admin/login?notice=reset_email_sent", status_code=303)
3703 except Exception as exc:
3704 LOGGER.warning("Forgot-password request failed: %s", exc)
3705 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=server_error", status_code=303)
3708@admin_router.get("/reset-password/{token}")
3709async def admin_reset_password_page(token: str, request: Request, db: Session = Depends(get_db)) -> Response:
3710 """Render password reset form for a token.
3712 Args:
3713 token: One-time reset token.
3714 request: Incoming HTTP request.
3715 db: Database session dependency.
3717 Returns:
3718 Response: Reset-password page response.
3719 """
3720 root_path = settings.app_root_path
3721 if not getattr(settings, "email_auth_enabled", False):
3722 return RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
3723 if not getattr(settings, "password_reset_enabled", True):
3724 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303)
3726 auth_service = EmailAuthService(db)
3727 token_valid = False
3728 token_error = None
3729 try:
3730 await auth_service.validate_password_reset_token(token=token, ip_address=get_client_ip(request), user_agent=get_user_agent(request))
3731 token_valid = True
3732 except AuthenticationError as exc:
3733 token_error = str(exc)
3735 return request.app.state.templates.TemplateResponse(
3736 request,
3737 "reset-password.html",
3738 {
3739 "request": request,
3740 "root_path": root_path,
3741 "token": token,
3742 "token_valid": token_valid,
3743 "token_error": token_error,
3744 "password_min_length": settings.password_min_length,
3745 "ui_airgapped": settings.mcpgateway_ui_airgapped,
3746 },
3747 )
3750@admin_router.post("/reset-password/{token}")
3751async def admin_reset_password_handler(token: str, request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
3752 """Handle password reset form submission.
3754 Args:
3755 token: One-time reset token.
3756 request: Incoming HTTP request with reset form data.
3757 db: Database session dependency.
3759 Returns:
3760 RedirectResponse: Redirect to login or reset page with status.
3761 """
3762 root_path = request.scope.get("root_path", "")
3763 if not getattr(settings, "email_auth_enabled", False):
3764 return RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
3765 if not getattr(settings, "password_reset_enabled", True):
3766 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303)
3768 try:
3769 form = await request.form()
3770 password = str(form.get("password", ""))
3771 confirm_password = str(form.get("confirm_password", ""))
3772 if not password or not confirm_password:
3773 return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=missing_fields", status_code=303)
3774 if password != confirm_password:
3775 return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=password_mismatch", status_code=303)
3777 auth_service = EmailAuthService(db)
3778 await auth_service.reset_password_with_token(token=token, new_password=password, ip_address=get_client_ip(request), user_agent=get_user_agent(request))
3779 return RedirectResponse(url=f"{root_path}/admin/login?notice=password_reset_success", status_code=303)
3780 except PasswordValidationError as exc:
3781 return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error={urllib.parse.quote(str(exc))}", status_code=303)
3782 except AuthenticationError as exc:
3783 msg = str(exc).lower()
3784 if "expired" in msg:
3785 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_expired", status_code=303)
3786 if "used" in msg:
3787 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_used", status_code=303)
3788 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_invalid", status_code=303)
3789 except Exception as exc:
3790 LOGGER.warning("Password reset failed: %s", exc)
3791 return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=server_error", status_code=303)
3794async def _admin_logout(request: Request) -> Response:
3795 """
3796 Handle admin logout by clearing authentication cookies.
3798 Supports both GET and POST methods:
3799 - POST: User-initiated logout from the UI (redirects to login page)
3800 - GET: OIDC front-channel logout from identity provider (returns 200 OK)
3802 For OIDC front-channel logout, Microsoft Entra ID sends GET requests to notify
3803 the application that the user has logged out from the IdP. The application
3804 should clear the session and return HTTP 200.
3806 Args:
3807 request (Request): FastAPI request object.
3809 Returns:
3810 Response: RedirectResponse for POST, or Response with 200 for GET (front-channel logout).
3812 Examples:
3813 >>> from fastapi import Request
3814 >>> from fastapi.responses import RedirectResponse, Response
3815 >>> from unittest.mock import MagicMock
3816 >>>
3817 >>> # Mock POST request (user-initiated)
3818 >>> mock_request = MagicMock(spec=Request)
3819 >>> mock_request.scope = {"root_path": "/test"}
3820 >>> mock_request.method = "POST"
3821 >>>
3822 >>> import asyncio
3823 >>> async def test_logout_post():
3824 ... response = await _admin_logout(mock_request)
3825 ... return isinstance(response, RedirectResponse) and response.status_code == 303
3826 >>>
3827 >>> asyncio.run(test_logout_post())
3828 True
3830 >>> # Mock GET request (front-channel logout)
3831 >>> mock_request.method = "GET"
3832 >>> async def test_logout_get():
3833 ... response = await _admin_logout(mock_request)
3834 ... return response.status_code == 200
3835 >>>
3836 >>> asyncio.run(test_logout_get())
3837 True
3838 """
3840 async def _extract_auth_provider_from_jwt_cookie() -> Optional[str]:
3841 """Best-effort auth provider resolution from the current JWT cookie.
3843 Returns:
3844 Optional[str]: Auth provider from JWT payload, if available.
3845 """
3846 cookies = getattr(request, "cookies", None)
3847 if not cookies or not hasattr(cookies, "get"):
3848 return None
3849 token = cookies.get("jwt_token")
3850 if not isinstance(token, str) or not token:
3851 return None
3852 try:
3853 payload = await verify_jwt_token_cached(token, request)
3854 except Exception as exc: # nosec B110 - best-effort provider detection during logout
3855 LOGGER.warning("Failed to verify JWT during logout - SSO session may not be cleared: %s", exc)
3856 if settings.sso_keycloak_enabled:
3857 return "keycloak"
3858 return None
3860 user_payload = payload.get("user")
3861 if isinstance(user_payload, dict):
3862 user_provider = user_payload.get("auth_provider")
3863 if isinstance(user_provider, str) and user_provider:
3864 return user_provider
3866 auth_provider = payload.get("auth_provider")
3867 if isinstance(auth_provider, str) and auth_provider:
3868 return auth_provider
3869 return None
3871 def _build_absolute_login_url(root_path: str) -> Optional[str]:
3872 """Build an absolute login URL using request URL, with app_domain fallback.
3874 Args:
3875 root_path (str): Application root path from request scope.
3877 Returns:
3878 Optional[str]: Absolute login URL when resolvable, otherwise ``None``.
3879 """
3880 login_path = f"{root_path}/admin/login"
3881 request_url = getattr(request, "url", None)
3882 scheme = getattr(request_url, "scheme", None) if request_url is not None else None
3883 netloc = getattr(request_url, "netloc", None) if request_url is not None else None
3884 if isinstance(scheme, str) and scheme and isinstance(netloc, str) and netloc:
3885 return f"{scheme}://{netloc}{login_path}"
3887 app_domain = str(getattr(settings, "app_domain", "") or "").rstrip("/")
3888 if app_domain:
3889 return f"{app_domain}{login_path}"
3890 return None
3892 def _build_keycloak_logout_url(root_path: str) -> Optional[str]:
3893 """Build Keycloak RP-initiated logout URL when all required config is available.
3895 Args:
3896 root_path (str): Application root path from request scope.
3898 Returns:
3899 Optional[str]: Keycloak logout URL when all inputs are valid, otherwise ``None``.
3900 """
3901 if not settings.sso_keycloak_enabled or not settings.sso_keycloak_base_url:
3902 return None
3904 login_url = _build_absolute_login_url(root_path)
3905 if not login_url:
3906 LOGGER.warning("Cannot build Keycloak logout URL: unable to resolve absolute login URL")
3907 return None
3909 keycloak_base = (settings.sso_keycloak_public_base_url or settings.sso_keycloak_base_url or "").rstrip("/")
3910 realm = str(settings.sso_keycloak_realm or "").strip()
3911 if not keycloak_base or not realm:
3912 LOGGER.warning("Cannot build Keycloak logout URL: missing keycloak_base or realm configuration")
3913 return None
3915 logout_endpoint = f"{keycloak_base}/realms/{urllib.parse.quote(realm, safe='')}/protocol/openid-connect/logout"
3916 query_params: Dict[str, str] = {
3917 "post_logout_redirect_uri": login_url,
3918 # Legacy Keycloak compatibility
3919 "redirect_uri": login_url,
3920 }
3921 if settings.sso_keycloak_client_id:
3922 query_params["client_id"] = settings.sso_keycloak_client_id
3924 cookies = getattr(request, "cookies", None)
3925 if cookies and hasattr(cookies, "get"):
3926 id_token_hint = cookies.get("sso_id_token_hint")
3927 if isinstance(id_token_hint, str) and id_token_hint:
3928 # Only include the hint if the id_token has not expired.
3929 # Keycloak rejects expired id_token_hint with an error page.
3930 try:
3931 payload_b64 = id_token_hint.split(".")[1]
3932 payload_b64 += "=" * (-len(payload_b64) % 4) # pad base64
3933 claims = orjson.loads(binascii.a2b_base64(payload_b64))
3934 if claims.get("exp", 0) > time.time():
3935 query_params["id_token_hint"] = id_token_hint
3936 else:
3937 LOGGER.info("Omitting expired id_token_hint from Keycloak logout URL")
3938 except Exception:
3939 LOGGER.debug("Could not decode id_token_hint; omitting from logout URL")
3941 return f"{logout_endpoint}?{urllib.parse.urlencode(query_params)}"
3943 LOGGER.info(f"Admin user logging out (method: {request.method})")
3944 root_path = request.scope.get("root_path", "")
3946 # For GET requests (OIDC front-channel logout), return 200 OK per OIDC spec.
3947 if request.method == "GET":
3948 response = Response(content="Logged out", status_code=200)
3949 else:
3950 response = RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
3952 auth_provider = await _extract_auth_provider_from_jwt_cookie()
3953 if auth_provider == "keycloak":
3954 keycloak_logout_url = _build_keycloak_logout_url(root_path)
3955 if keycloak_logout_url:
3956 LOGGER.info("Redirecting to Keycloak RP-initiated logout endpoint")
3957 response = RedirectResponse(url=keycloak_logout_url, status_code=303)
3959 # Always clear local JWT session cookie.
3960 clear_auth_cookie(response)
3961 use_secure = (settings.environment == "production") or settings.secure_cookies
3962 response.delete_cookie(
3963 key="sso_id_token_hint",
3964 path=settings.app_root_path or "/",
3965 secure=use_secure,
3966 httponly=True,
3967 samesite=settings.cookie_samesite,
3968 )
3969 return response
3972@admin_router.get("/logout", operation_id="admin_logout_get")
3973async def admin_logout_get(request: Request) -> Response:
3974 """GET logout endpoint for OIDC front-channel logout.
3976 Args:
3977 request (Request): FastAPI request object.
3979 Returns:
3980 Response: Logout response for front-channel requests.
3981 """
3982 return await _admin_logout(request)
3985@admin_router.post("/logout", operation_id="admin_logout_post")
3986async def admin_logout_post(request: Request) -> Response:
3987 """POST logout endpoint for user-initiated UI logout.
3989 Args:
3990 request (Request): FastAPI request object.
3992 Returns:
3993 Response: Logout response for UI-initiated requests.
3994 """
3995 return await _admin_logout(request)
3998@admin_router.get("/change-password-required", response_class=HTMLResponse)
3999async def change_password_required_page(request: Request) -> HTMLResponse:
4000 """
4001 Render the password change required page.
4003 This page is shown when a user's password has expired and must be changed
4004 to continue accessing the system.
4006 Args:
4007 request (Request): FastAPI request object.
4009 Returns:
4010 HTMLResponse: The password change required page.
4012 Examples:
4013 >>> from unittest.mock import MagicMock
4014 >>> from fastapi import Request
4015 >>> from fastapi.responses import HTMLResponse
4016 >>>
4017 >>> # Mock request
4018 >>> mock_request = MagicMock(spec=Request)
4019 >>> mock_request.scope = {"root_path": "/test"}
4020 >>> mock_request.app.state.templates = MagicMock()
4021 >>> mock_response = HTMLResponse("<html>Change Password</html>")
4022 >>> mock_request.app.state.templates.TemplateResponse.return_value = mock_response
4023 >>>
4024 >>> import asyncio
4025 >>> async def test_change_password_page():
4026 ... # Note: This requires email_auth_enabled=True in settings
4027 ... return True # Simplified test due to settings dependency
4028 >>>
4029 >>> asyncio.run(test_change_password_page())
4030 True
4031 """
4032 if not getattr(settings, "email_auth_enabled", False):
4033 root_path = request.scope.get("root_path", "")
4034 return RedirectResponse(url=f"{root_path}/admin", status_code=303)
4036 # Get root path for template
4037 root_path = request.scope.get("root_path", "")
4039 return request.app.state.templates.TemplateResponse(
4040 request,
4041 "change-password-required.html",
4042 {
4043 "request": request,
4044 "root_path": root_path,
4045 "ui_airgapped": settings.mcpgateway_ui_airgapped,
4046 "password_policy_enabled": getattr(settings, "password_policy_enabled", True),
4047 "password_min_length": getattr(settings, "password_min_length", 8),
4048 "password_require_uppercase": getattr(settings, "password_require_uppercase", False),
4049 "password_require_lowercase": getattr(settings, "password_require_lowercase", False),
4050 "password_require_numbers": getattr(settings, "password_require_numbers", False),
4051 "password_require_special": getattr(settings, "password_require_special", False),
4052 },
4053 )
4056@admin_router.post("/change-password-required")
4057async def change_password_required_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
4058 """
4059 Handle password change requirement form submission.
4061 This endpoint processes the forced password change form, validates the credentials,
4062 changes the password, clears the password_change_required flag, and redirects to admin panel.
4064 Args:
4065 request (Request): FastAPI request object.
4066 db (Session): Database session dependency.
4068 Returns:
4069 RedirectResponse: Redirect to admin panel on success or back to form with error.
4071 Examples:
4072 >>> from unittest.mock import MagicMock, AsyncMock
4073 >>> from fastapi import Request
4074 >>> from fastapi.responses import RedirectResponse
4075 >>>
4076 >>> # Mock request with form data
4077 >>> mock_request = MagicMock(spec=Request)
4078 >>> mock_request.scope = {"root_path": "/test"}
4079 >>> mock_form = {
4080 ... "current_password": "oldpass",
4081 ... "new_password": "newpass123",
4082 ... "confirm_password": "newpass123"
4083 ... }
4084 >>> mock_request.form = AsyncMock(return_value=mock_form)
4085 >>> mock_request.cookies = {"jwt_token": "test_token"}
4086 >>> mock_request.headers = {"User-Agent": "TestAgent"}
4087 >>>
4088 >>> mock_db = MagicMock()
4089 >>>
4090 >>> import asyncio
4091 >>> async def test_password_change_handler():
4092 ... # Note: Full test requires email_auth_enabled and valid JWT
4093 ... return True # Simplified test due to settings/auth dependencies
4094 >>>
4095 >>> asyncio.run(test_password_change_handler())
4096 True
4097 """
4098 if not getattr(settings, "email_auth_enabled", False):
4099 root_path = request.scope.get("root_path", "")
4100 return RedirectResponse(url=f"{root_path}/admin", status_code=303)
4102 try:
4103 form = await request.form()
4104 current_password_val = form.get("current_password")
4105 new_password_val = form.get("new_password")
4106 confirm_password_val = form.get("confirm_password")
4108 current_password = current_password_val if isinstance(current_password_val, str) else None
4109 new_password = new_password_val if isinstance(new_password_val, str) else None
4110 confirm_password = confirm_password_val if isinstance(confirm_password_val, str) else None
4112 if not all([current_password, new_password, confirm_password]):
4113 root_path = request.scope.get("root_path", "")
4114 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=missing_fields", status_code=303)
4116 if new_password != confirm_password:
4117 root_path = request.scope.get("root_path", "")
4118 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=mismatch", status_code=303)
4120 # Get user from JWT token in cookie
4121 try:
4122 jwt_token = request.cookies.get("jwt_token")
4123 current_user = None
4124 if jwt_token:
4125 credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=jwt_token)
4126 current_user = await get_current_user(credentials, request=request)
4127 except Exception as e:
4128 LOGGER.error(f"Authentication error: {e}")
4129 current_user = None
4131 if not current_user:
4132 root_path = request.scope.get("root_path", "")
4133 return RedirectResponse(url=f"{root_path}/admin/login?error=session_expired", status_code=303)
4135 # Authenticate using the email auth service
4136 auth_service = EmailAuthService(db)
4137 ip_address = get_client_ip(request)
4138 user_agent = get_user_agent(request)
4140 try:
4141 # Change password
4142 success = await auth_service.change_password(email=current_user.email, old_password=current_password, new_password=new_password, ip_address=ip_address, user_agent=user_agent)
4144 if success:
4145 # Re-attach current_user to session for downstream use (e.g., get_teams() in token creation)
4146 # Note: password_change_required is already cleared by auth_service.change_password()
4147 # We must re-attach to ensure team claims are populated in the new JWT token.
4148 user_email = current_user.email # Save before potential re-query
4149 try:
4150 # pylint: disable=import-outside-toplevel
4151 # Third-Party
4152 from sqlalchemy import inspect as sa_inspect
4154 # First-Party
4155 from mcpgateway.db import EmailUser
4157 insp = sa_inspect(current_user)
4158 if insp.transient or insp.detached:
4159 current_user = db.query(EmailUser).filter(EmailUser.email == user_email).first()
4160 if current_user is None:
4161 LOGGER.error(f"User {user_email} not found after successful password change - possible race condition")
4162 root_path = request.scope.get("root_path", "")
4163 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303)
4164 except Exception as e:
4165 # Return early to avoid creating token with empty team claims
4166 LOGGER.error(f"Failed to re-attach user {user_email} to session: {e} - password changed but token creation skipped")
4167 root_path = request.scope.get("root_path", "")
4168 return RedirectResponse(url=f"{root_path}/admin/login?message=password_changed", status_code=303)
4170 # Create new JWT token
4171 token, _ = await create_access_token(current_user)
4173 # Create redirect response to admin panel
4174 root_path = request.scope.get("root_path", "")
4175 response = RedirectResponse(url=f"{root_path}/admin", status_code=303)
4177 # Update JWT token cookie
4178 try:
4179 set_auth_cookie(response, token, remember_me=False)
4180 except CookieTooLargeError:
4181 return RedirectResponse(
4182 url=f"{root_path}/admin/login?error=token_too_large",
4183 status_code=303,
4184 )
4186 LOGGER.info(f"User {current_user.email} successfully changed their expired password")
4187 return response
4189 root_path = request.scope.get("root_path", "")
4190 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=change_failed", status_code=303)
4192 except AuthenticationError:
4193 root_path = request.scope.get("root_path", "")
4194 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=invalid_password", status_code=303)
4195 except PasswordValidationError as e:
4196 LOGGER.warning(f"Password validation failed for {current_user.email}: {e}")
4197 root_path = request.scope.get("root_path", "")
4198 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=weak_password", status_code=303)
4199 except Exception as e:
4200 LOGGER.error(f"Password change failed for {current_user.email}: {e}", exc_info=True)
4201 root_path = request.scope.get("root_path", "")
4202 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303)
4204 except Exception as e:
4205 LOGGER.error(f"Password change handler error: {e}")
4206 root_path = request.scope.get("root_path", "")
4207 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303)
4210# ============================================================================ #
4211# TEAM ADMIN ROUTES #
4212# ============================================================================ #
4215async def _generate_unified_teams_view(team_service, current_user, root_path): # pylint: disable=unused-argument
4216 """Generate unified team view with relationship badges.
4218 Args:
4219 team_service: Service for team operations
4220 current_user: Current authenticated user
4221 root_path: Application root path
4223 Returns:
4224 HTML string containing the unified teams view
4225 """
4226 # Get user's teams (owned + member)
4227 user_teams = await team_service.get_user_teams(current_user.email)
4229 # Get public teams user can join
4230 public_teams = await team_service.discover_public_teams(current_user.email)
4232 # Batch fetch ALL data upfront - 3 queries instead of 3N queries (N+1 elimination)
4233 user_team_ids = [str(t.id) for t in user_teams]
4234 public_team_ids = [str(t.id) for t in public_teams]
4235 all_team_ids = user_team_ids + public_team_ids
4237 member_counts = await team_service.get_member_counts_batch_cached(all_team_ids)
4238 user_roles = team_service.get_user_roles_batch(current_user.email, user_team_ids)
4239 pending_requests = team_service.get_pending_join_requests_batch(current_user.email, public_team_ids)
4241 # Combine teams with relationship information
4242 all_teams = []
4244 # Add user's teams (owned and member)
4245 for team in user_teams:
4246 team_id = str(team.id)
4247 user_role = user_roles.get(team_id)
4248 relationship = "owner" if user_role == "owner" else "member"
4249 all_teams.append({"team": team, "relationship": relationship, "member_count": member_counts.get(team_id, 0)})
4251 # Add public teams user can join
4252 for team in public_teams:
4253 team_id = str(team.id)
4254 pending_request = pending_requests.get(team_id)
4255 relationship_data = {"team": team, "relationship": "join", "member_count": member_counts.get(team_id, 0), "pending_request": pending_request}
4256 all_teams.append(relationship_data)
4258 # Generate HTML for unified team view
4259 teams_html = ""
4260 for item in all_teams:
4261 team = item["team"]
4262 relationship = item["relationship"]
4263 member_count = item["member_count"]
4264 pending_request = item.get("pending_request")
4266 # Relationship badge - special handling for personal teams
4267 if team.is_personal:
4268 badge_html = '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">PERSONAL</span>'
4269 elif relationship == "owner":
4270 badge_html = (
4271 '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">OWNER</span>'
4272 )
4273 elif relationship == "member":
4274 badge_html = (
4275 '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">MEMBER</span>'
4276 )
4277 else: # join
4278 badge_html = '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300">CAN JOIN</span>'
4280 # Visibility badge
4281 visibility_badge = (
4282 f'<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">{team.visibility.upper()}</span>'
4283 )
4285 # Subtitle based on relationship - special handling for personal teams
4286 if team.is_personal:
4287 subtitle = "Your personal team • Private workspace"
4288 elif relationship == "owner":
4289 subtitle = "You own this team"
4290 elif relationship == "member":
4291 subtitle = f"You are a member • Owner: {team.created_by}"
4292 else: # join
4293 subtitle = f"Public team • Owner: {team.created_by}"
4295 # Escape team name for safe HTML attributes
4296 safe_team_name = html.escape(team.name)
4298 # Actions based on relationship - special handling for personal teams
4299 actions_html = ""
4300 if team.is_personal:
4301 # Personal teams have no management actions - they're private workspaces
4302 actions_html = """
4303 <div class="flex flex-wrap gap-2 mt-3">
4304 <span class="px-3 py-1 text-sm font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-md">
4305 Personal workspace - no actions available
4306 </span>
4307 </div>
4308 """
4309 elif relationship == "owner":
4310 delete_button = f'<button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="deleteTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Delete Team</button>'
4311 join_requests_button = (
4312 f'<button data-team-id="{team.id}" onclick="viewJoinRequestsSafe(this)" class="px-3 py-1 text-sm font-medium text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300 border border-purple-300 dark:border-purple-600 hover:border-purple-500 dark:hover:border-purple-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">Join Requests</button>'
4313 if team.visibility == "public"
4314 else ""
4315 )
4316 actions_html = f"""
4317 <div class="flex flex-wrap gap-2 mt-3">
4318 <button data-team-id="{team.id}" onclick="manageTeamMembersSafe(this)" class="px-3 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 border border-blue-300 dark:border-blue-600 hover:border-blue-500 dark:hover:border-blue-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
4319 Manage Members
4320 </button>
4321 <button data-team-id="{team.id}" onclick="editTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
4322 Edit Settings
4323 </button>
4324 {join_requests_button}
4325 {delete_button}
4326 </div>
4327 """
4328 elif relationship == "member":
4329 leave_button = f'<button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="leaveTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300 border border-orange-300 dark:border-orange-600 hover:border-orange-500 dark:hover:border-orange-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500">Leave Team</button>'
4330 actions_html = f"""
4331 <div class="flex flex-wrap gap-2 mt-3">
4332 {leave_button}
4333 </div>
4334 """
4335 else: # join
4336 if pending_request:
4337 # Show "Requested to Join [Cancel Request]" state
4338 actions_html = f"""
4339 <div class="flex flex-wrap gap-2 mt-3">
4340 <span class="px-3 py-1 text-sm font-medium text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900 rounded-md border border-yellow-300 dark:border-yellow-600">
4341 ⏳ Requested to Join
4342 </span>
4343 <button onclick="cancelJoinRequest('{team.id}', '{pending_request.id}')" class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
4344 Cancel Request
4345 </button>
4346 </div>
4347 """
4348 else:
4349 # Show "Request to Join" button
4350 actions_html = f"""
4351 <div class="flex flex-wrap gap-2 mt-3">
4352 <button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="requestToJoinTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
4353 Request to Join
4354 </button>
4355 </div>
4356 """
4358 # Truncated description (properly escaped)
4359 description_text = ""
4360 if team.description:
4361 safe_description = html.escape(team.description)
4362 truncated = safe_description[:80] + "..." if len(safe_description) > 80 else safe_description
4363 description_text = f'<p class="team-description text-sm text-gray-600 dark:text-gray-400 mt-1">{truncated}</p>'
4365 teams_html += f"""
4366 <div class="team-card bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow" data-relationship="{relationship}">
4367 <div class="flex justify-between items-start mb-3">
4368 <div class="flex-1">
4369 <div class="flex items-center gap-3 mb-2">
4370 <h4 class="team-name text-lg font-medium text-gray-900 dark:text-white">🏢 {safe_team_name}</h4>
4371 {badge_html}
4372 {visibility_badge}
4373 <span class="text-sm text-gray-500 dark:text-gray-400">{member_count} members</span>
4374 </div>
4375 <p class="text-sm text-gray-600 dark:text-gray-400">{subtitle}</p>
4376 {description_text}
4377 </div>
4378 </div>
4379 {actions_html}
4380 </div>
4381 """
4383 if not teams_html:
4384 teams_html = '<div class="text-center py-12"><p class="text-gray-500 dark:text-gray-400">No teams found. Create your first team using the button above.</p></div>'
4386 return HTMLResponse(content=teams_html)
4389@admin_router.get("/teams/ids", response_class=JSONResponse)
4390@require_permission("teams.read", allow_admin_bypass=False)
4391async def admin_get_all_team_ids(
4392 include_inactive: bool = False,
4393 visibility: Optional[str] = Query(None, description="Filter by visibility"),
4394 q: Optional[str] = Query(None, description="Search query"),
4395 db: Session = Depends(get_db),
4396 user=Depends(get_current_user_with_permissions),
4397):
4398 """Return all team IDs accessible to the current user.
4400 Args:
4401 include_inactive (bool): Whether to include inactive teams.
4402 visibility (Optional[str]): Filter by team visibility.
4403 q (Optional[str]): Search query string.
4404 db (Session): Database session dependency.
4405 user: Current authenticated user.
4407 Returns:
4408 JSONResponse: Dictionary with list of team IDs and count.
4409 """
4410 team_service = TeamManagementService(db)
4411 user_email = get_user_email(user)
4413 auth_service = EmailAuthService(db)
4414 current_user = await auth_service.get_user_by_email(user_email)
4416 if not current_user:
4417 return {"team_ids": [], "count": 0}
4419 # If admin, get all teams (filtered)
4420 # If regular user, get user teams + accessible public teams?
4421 # For now, admin only per usage pattern?
4422 # But tools/ids handles team_id scoping. Here we filter by teams user can see.
4423 # get_all_team_ids supports search/visibility.
4425 # Check admin
4426 if current_user.is_admin:
4427 team_ids = await team_service.get_all_team_ids(include_inactive=include_inactive, visibility_filter=visibility, include_personal=True, search_query=q)
4428 else:
4429 # For non-admins, get user's teams + public teams logic?
4430 # get_user_teams gets all teams user is in.
4431 # discover_public_teams gets public teams.
4432 # unified search across them?
4433 # Simpler: just reuse list_teams logic but with huge limit?
4434 # Or, just return user's teams IDs filtering in memory (since user won't have millions of teams)
4435 all_teams = await team_service.get_user_teams(user_email, include_personal=True)
4436 # Apply filters
4437 # Note: get_user_teams includes visibility/inactive implicitly? No, it returns what they are member of.
4438 # But we might need public teams too?
4439 # Let's align with list_teams logic.
4441 filtered = []
4442 for t in all_teams:
4443 if not include_inactive and not t.is_active:
4444 continue
4445 if visibility and t.visibility != visibility:
4446 continue
4447 if q:
4448 if q.lower() not in t.name.lower() and q.lower() not in t.slug.lower():
4449 continue
4450 filtered.append(t.id)
4451 team_ids = filtered
4453 return {"team_ids": team_ids, "count": len(team_ids)}
4456@admin_router.get("/teams/search", response_class=JSONResponse)
4457@require_permission("teams.read", allow_admin_bypass=False)
4458async def admin_search_teams(
4459 q: str = Query("", description="Search query"),
4460 include_inactive: bool = False,
4461 limit: int = Query(settings.pagination_default_page_size, ge=1, le=100, description="Max results"),
4462 visibility: Optional[str] = Query(None, description="Filter by visibility"),
4463 db: Session = Depends(get_db),
4464 user=Depends(get_current_user_with_permissions),
4465):
4466 """Search teams by name/slug/description.
4468 Args:
4469 q (str): Search query string.
4470 include_inactive (bool): Whether to include inactive teams.
4471 limit (int): Maximum number of results to return.
4472 visibility (Optional[str]): Filter by team visibility.
4473 db (Session): Database session dependency.
4474 user: Current authenticated user.
4476 Returns:
4477 JSONResponse: List of matching teams with basic info.
4478 """
4479 search_query = _normalize_search_query(q)
4480 team_service = TeamManagementService(db)
4481 user_email = get_user_email(user)
4483 auth_service = EmailAuthService(db)
4484 current_user = await auth_service.get_user_by_email(user_email)
4486 if not current_user:
4487 return []
4489 # Use list_teams logic
4490 # For admin: search globally
4491 # For user: search user teams (and maybe public?)
4492 # existing list_teams handles this via include_personal/logic?
4493 # list_teams handles admin vs user distinction?
4494 # Wait, list_teams in service doesn't know about user per se. It lists ALL teams based on query.
4495 # The CALLER (admin.py) distinguishes.
4497 if current_user.is_admin:
4498 result = await team_service.list_teams(page=1, per_page=limit, include_inactive=include_inactive, visibility_filter=visibility, include_personal=True, search_query=search_query)
4499 # Result is dict {data, pagination...} (since page provided)
4500 teams = result["data"]
4501 else:
4502 # Non-admin search
4503 # Reuse user team fetching
4504 all_teams = await team_service.get_user_teams(user_email, include_personal=True)
4505 # Filter in memory
4506 filtered = []
4507 for t in all_teams:
4508 if not include_inactive and not t.is_active:
4509 continue
4510 if visibility and t.visibility != visibility:
4511 continue
4512 if search_query:
4513 description_text = (t.description or "").lower()
4514 if search_query not in t.name.lower() and search_query not in t.slug.lower() and search_query not in description_text:
4515 continue
4516 filtered.append(t)
4518 # Paginate manually
4519 teams = filtered[:limit]
4521 serialized_teams = [{"id": t.id, "name": t.name, "slug": t.slug, "description": t.description, "visibility": t.visibility, "is_active": t.is_active} for t in teams]
4522 return serialized_teams
4525@admin_router.get("/teams/partial")
4526@require_permission("teams.read", allow_admin_bypass=False)
4527async def admin_teams_partial_html(
4528 request: Request,
4529 page: int = Query(1, ge=1, description="Page number"),
4530 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=100, description="Items per page"),
4531 include_inactive: bool = Query(False, description="Include inactive teams"),
4532 visibility: Optional[str] = Query(None, description="Filter by visibility"),
4533 render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"),
4534 q: Optional[str] = Query(None, description="Search query"),
4535 relationship: Optional[str] = Query(None, description="Filter by relationship: owner, member, public"),
4536 db: Session = Depends(get_db),
4537 user=Depends(get_current_user_with_permissions),
4538) -> HTMLResponse:
4539 """Return HTML partial for paginated teams list (HTMX).
4541 Args:
4542 request (Request): FastAPI request object.
4543 page (int): Page number for pagination.
4544 per_page (int): Number of items per page.
4545 include_inactive (bool): Whether to include inactive teams.
4546 visibility (Optional[str]): Filter by team visibility.
4547 render (Optional[str]): Render mode, e.g., 'controls' for pagination controls only.
4548 q (Optional[str]): Search query string.
4549 relationship (Optional[str]): Filter by relationship: owner, member, public.
4550 db (Session): Database session dependency.
4551 user: Current authenticated user.
4553 Returns:
4554 HTMLResponse: Rendered HTML partial for teams list or pagination controls.
4556 """
4557 team_service = TeamManagementService(db)
4558 user_email = get_user_email(user)
4559 root_path = request.scope.get("root_path", "")
4561 # Base URL for pagination links - preserve search query and relationship filter
4562 base_url = f"{root_path}/admin/teams/partial"
4563 query_parts = []
4564 if q:
4565 query_parts.append(f"q={urllib.parse.quote(q, safe='')}")
4566 if relationship:
4567 query_parts.append(f"relationship={urllib.parse.quote(relationship, safe='')}")
4568 if query_parts:
4569 base_url += "?" + "&".join(query_parts)
4571 # Check permissions and get current user
4572 auth_service = EmailAuthService(db)
4573 current_user = await auth_service.get_user_by_email(user_email)
4575 if not current_user:
4576 return HTMLResponse(content='<div class="text-center py-8"><p class="text-red-500">User not found</p></div>', status_code=404)
4578 # Get user's teams and public teams for relationship info
4579 user_teams = await team_service.get_user_teams(user_email, include_personal=True)
4580 user_team_ids = {str(t.id) for t in user_teams}
4582 # Get user roles for owned/member distinction
4583 user_roles = team_service.get_user_roles_batch(user_email, list(user_team_ids))
4585 # Get public teams the user can join (not already a member)
4586 # NOTE: Limited to 500 for memory safety. Non-admin users with "public" filter
4587 # will only see up to 500 joinable teams. For deployments with >500 public teams,
4588 # consider implementing SQL-level pagination for non-admin users.
4589 public_teams_limit = 500
4590 public_teams = await team_service.discover_public_teams(user_email, limit=public_teams_limit)
4591 public_team_ids = {str(t.id) for t in public_teams}
4592 if len(public_teams) >= public_teams_limit:
4593 LOGGER.warning(f"Public teams discovery hit limit of {public_teams_limit} for user {user_email}. Some teams may not be visible.")
4595 # Get pending join requests for public teams
4596 pending_requests = team_service.get_pending_join_requests_batch(user_email, list(public_team_ids))
4598 if current_user.is_admin and not relationship:
4599 # Admin sees all teams when no relationship filter
4600 paginated_result = await team_service.list_teams(
4601 page=page, per_page=per_page, include_inactive=include_inactive, visibility_filter=visibility, base_url=base_url, include_personal=True, search_query=q
4602 )
4603 data = paginated_result["data"]
4604 pagination = paginated_result["pagination"]
4605 links = paginated_result["links"]
4606 else:
4607 # Filter by relationship or regular user view
4608 all_teams = []
4610 if relationship == "owner":
4611 # Only teams user owns
4612 all_teams = [t for t in user_teams if user_roles.get(str(t.id)) == "owner"]
4613 elif relationship == "member":
4614 # Only teams user is a member of (not owner)
4615 all_teams = [t for t in user_teams if user_roles.get(str(t.id)) == "member"]
4616 elif relationship == "public":
4617 # Only public teams user can join
4618 all_teams = list(public_teams)
4619 else:
4620 # All teams: user's teams + public teams they can join
4621 all_teams = list(user_teams) + list(public_teams)
4623 # Apply search filter
4624 if q:
4625 q_lower = q.lower()
4626 all_teams = [t for t in all_teams if q_lower in t.name.lower() or q_lower in (t.slug or "").lower() or q_lower in (t.description or "").lower()]
4628 # Apply visibility filter
4629 if visibility:
4630 all_teams = [t for t in all_teams if t.visibility == visibility]
4632 if not include_inactive:
4633 all_teams = [t for t in all_teams if t.is_active]
4635 total = len(all_teams)
4636 total_pages = math.ceil(total / per_page) if per_page else 1
4637 # Clamp page to valid range (matches offset_paginate behavior)
4638 if total_pages > 0:
4639 page = min(page, total_pages)
4640 start = (page - 1) * per_page
4641 end = start + per_page
4642 data = all_teams[start:end]
4644 pagination = PaginationMeta(page=page, per_page=per_page, total_items=total, total_pages=total_pages, has_next=end < total, has_prev=page > 1)
4645 links = None
4647 if render == "controls":
4648 # Return only pagination controls
4649 return request.app.state.templates.TemplateResponse(
4650 request,
4651 "pagination_controls.html",
4652 {
4653 "request": request,
4654 "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(),
4655 "links": links.model_dump() if links and not isinstance(links, dict) else links,
4656 "root_path": root_path,
4657 "hx_target": "#unified-teams-list",
4658 "hx_indicator": "#teams-loading",
4659 "query_params": {"include_inactive": include_inactive, "visibility": visibility, "q": q, "relationship": relationship},
4660 "base_url": base_url,
4661 },
4662 )
4664 if render == "selector":
4665 # Return team selector items for infinite scroll dropdown
4666 # Add member counts for display
4667 team_ids = [str(t.id) for t in data]
4668 counts = await team_service.get_member_counts_batch_cached(team_ids)
4669 for t in data:
4670 t.member_count = counts.get(str(t.id), 0)
4672 query_params_dict = {}
4673 if q:
4674 query_params_dict["q"] = q
4676 return request.app.state.templates.TemplateResponse(
4677 request,
4678 "teams_selector_items.html",
4679 {
4680 "request": request,
4681 "data": data,
4682 "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(),
4683 "root_path": root_path,
4684 "query_params": query_params_dict,
4685 },
4686 )
4688 # Batch count members
4689 team_ids = [str(t.id) for t in data]
4690 counts = await team_service.get_member_counts_batch_cached(team_ids)
4692 # Build enriched data with relationship info
4693 enriched_data = []
4694 for t in data:
4695 team_id = str(t.id)
4696 t.member_count = counts.get(team_id, 0)
4698 # Determine relationship
4699 t.relationship = "none"
4700 t.pending_request = None
4701 if t.is_personal:
4702 t.relationship = "personal"
4703 elif team_id in user_team_ids:
4704 role = user_roles.get(team_id)
4705 t.relationship = "owner" if role == "owner" else "member"
4706 elif current_user.is_admin:
4707 # Admins get admin controls for teams they're not members of
4708 t.relationship = "none" # Falls through to admin controls in template
4709 elif team_id in public_team_ids:
4710 t.relationship = "public"
4711 t.pending_request = pending_requests.get(team_id)
4713 enriched_data.append(t)
4715 # Build query params dict for pagination controls
4716 query_params_dict = {}
4717 if q:
4718 query_params_dict["q"] = q
4719 if relationship:
4720 query_params_dict["relationship"] = relationship
4721 if include_inactive:
4722 query_params_dict["include_inactive"] = "true"
4723 if visibility:
4724 query_params_dict["visibility"] = visibility
4726 response = request.app.state.templates.TemplateResponse(
4727 request,
4728 "teams_partial.html",
4729 {
4730 "request": request,
4731 "data": enriched_data,
4732 "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(),
4733 "links": links.model_dump() if links and not isinstance(links, dict) else links,
4734 "root_path": root_path,
4735 "query_params": query_params_dict,
4736 },
4737 )
4738 # Prevent nginx caching for real-time team updates
4739 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
4740 response.headers["Pragma"] = "no-cache"
4741 response.headers["Expires"] = "0"
4742 return response
4745@admin_router.get("/teams")
4746@require_permission("teams.read", allow_admin_bypass=False)
4747async def admin_list_teams(
4748 request: Request,
4749 page: int = Query(1, ge=1, description="Page number"),
4750 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=100, description="Items per page"),
4751 q: Optional[str] = Query(None, description="Search query"),
4752 db: Session = Depends(get_db),
4753 user=Depends(get_current_user_with_permissions),
4754 unified: bool = False,
4755) -> HTMLResponse:
4756 """List teams for admin UI via HTMX.
4758 Args:
4759 request: FastAPI request object
4760 page: Page number
4761 per_page: Items per page
4762 q: Search query
4763 db: Database session
4764 user: Authenticated admin user
4765 unified: If True, return unified team view with relationship badges
4767 Returns:
4768 HTML response with teams list
4770 Raises:
4771 HTTPException: If email auth is disabled or user not found
4772 """
4773 if not getattr(settings, "email_auth_enabled", False):
4774 return HTMLResponse(content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. Teams feature requires email auth.</p></div>', status_code=200)
4776 try:
4777 auth_service = EmailAuthService(db)
4778 team_service = TeamManagementService(db)
4780 # Get current user
4781 user_email = get_user_email(user)
4782 current_user = await auth_service.get_user_by_email(user_email)
4783 if not current_user:
4784 return HTMLResponse(content='<div class="text-center py-8"><p class="text-red-500">User not found</p></div>', status_code=200)
4786 root_path = request.scope.get("root_path", "")
4788 if unified:
4789 # Generate unified team view
4790 return await _generate_unified_teams_view(team_service, current_user, root_path)
4792 # Traditional admin view refactored to use partial logic
4793 # We can reuse the logic by calling the service directly or redirecting?
4794 # Redirection requires a round trip. Calling logic allows server-side render.
4795 # We'll re-use the logic by calling default params.
4797 # Call list_teams logic (similar to admin_teams_partial_html but inline)
4798 if current_user.is_admin:
4799 # Default first page
4800 base_url = f"{root_path}/admin/teams/partial"
4801 if q:
4802 base_url += f"?q={urllib.parse.quote(q, safe='')}"
4804 paginated_result = await team_service.list_teams(page=page, per_page=per_page, base_url=base_url, include_personal=True, search_query=q)
4805 data = paginated_result["data"]
4806 pagination = paginated_result["pagination"]
4807 links = paginated_result["links"]
4808 else:
4809 all_teams = await team_service.get_user_teams(current_user.email, include_personal=True)
4810 # Basic pagination for user view
4811 total = len(all_teams)
4812 start = (page - 1) * per_page
4813 end = start + per_page
4814 data = all_teams[start:end]
4815 pagination = PaginationMeta(page=page, per_page=per_page, total_items=total, total_pages=math.ceil(total / per_page) if per_page else 1, has_next=end < total, has_prev=page > 1)
4816 links = None
4818 # Batch counts
4819 team_ids = [str(t.id) for t in data]
4820 counts = await team_service.get_member_counts_batch_cached(team_ids)
4821 for t in data:
4822 t.member_count = counts.get(str(t.id), 0)
4824 # Render template
4825 return request.app.state.templates.TemplateResponse(
4826 request,
4827 "teams_partial.html",
4828 {
4829 "request": request,
4830 "data": data,
4831 "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(),
4832 "links": links.model_dump() if links and not isinstance(links, dict) else links,
4833 "root_path": root_path,
4834 },
4835 )
4837 except Exception as e:
4838 LOGGER.error(f"Error listing teams for admin {user}: {e}")
4839 return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading teams: {html.escape(str(e))}</p></div>', status_code=200)
4842@admin_router.post("/teams")
4843@require_permission("teams.create", allow_admin_bypass=False)
4844async def admin_create_team(
4845 request: Request,
4846 db: Session = Depends(get_db),
4847 user=Depends(get_current_user_with_permissions),
4848) -> HTMLResponse:
4849 """Create team via admin UI form submission.
4851 Args:
4852 request: FastAPI request object
4853 db: Database session
4854 user: Authenticated admin user
4856 Returns:
4857 HTML response with new team or error message
4859 Raises:
4860 HTTPException: If email auth is disabled or validation fails
4861 """
4862 if not getattr(settings, "email_auth_enabled", False):
4863 error_content = '<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Email authentication is disabled</div>'
4864 response = HTMLResponse(content=error_content, status_code=403)
4865 return response
4867 try:
4868 form = await request.form()
4869 name = form.get("name")
4870 slug = form.get("slug") or None
4871 description = form.get("description") or None
4872 visibility = form.get("visibility", "private")
4874 if not name:
4875 response = HTMLResponse(
4876 content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Team name is required</div>',
4877 status_code=400,
4878 )
4879 return response
4881 # Create team
4882 # First-Party
4883 from mcpgateway.schemas import TeamCreateRequest # pylint: disable=import-outside-toplevel
4885 team_service = TeamManagementService(db)
4887 team_data = TeamCreateRequest(name=name, slug=slug, description=description, visibility=visibility)
4889 # Extract user email from user dict
4890 user_email = get_user_email(user)
4892 await team_service.create_team(name=team_data.name, description=team_data.description, created_by=user_email, visibility=team_data.visibility)
4894 response = HTMLResponse(content="", status_code=201)
4895 return response
4897 except (ValidationError, CoreValidationError) as e:
4898 LOGGER.warning(f"Validation error creating team: {e}")
4899 # Extract user-friendly error message from Pydantic validation error
4900 error_messages = []
4901 for error in e.errors():
4902 msg = error.get("msg", "Invalid value")
4903 # Clean up common Pydantic prefixes
4904 if msg.startswith("Value error, "):
4905 msg = msg[13:]
4906 error_messages.append(f"{msg}")
4907 error_text = "; ".join(error_messages) if error_messages else "Invalid input"
4908 response = HTMLResponse(
4909 content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">{html.escape(error_text)}</div>',
4910 status_code=400,
4911 )
4912 return response
4913 except IntegrityError as e:
4914 LOGGER.error(f"Error creating team for admin {user}: {e}")
4915 if "UNIQUE constraint failed: email_teams.slug" in str(e):
4916 error_content = '<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">A team with this name already exists. Please choose a different name.</div>'
4917 else:
4918 error_content = f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Database error: {html.escape(str(e))}</div>'
4919 response = HTMLResponse(content=error_content, status_code=400)
4920 return response
4921 except Exception as e:
4922 LOGGER.error(f"Error creating team for admin {user}: {e}")
4923 response = HTMLResponse(
4924 content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Error creating team: {html.escape(str(e))}</div>',
4925 status_code=400,
4926 )
4927 return response
4930@admin_router.get("/teams/{team_id}/members")
4931@require_permission("teams.read", allow_admin_bypass=False)
4932async def admin_view_team_members(
4933 team_id: str,
4934 request: Request,
4935 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
4936 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
4937 db: Session = Depends(get_db),
4938 user=Depends(get_current_user_with_permissions),
4939) -> HTMLResponse:
4940 """View and manage team members via admin UI (unified view).
4942 This replaces the old separate "view members" and "add members" screens with a unified
4943 interface that shows all users with checkboxes. Members are pre-checked and can be
4944 unchecked to remove them. Non-members can be checked to add them.
4946 Args:
4947 team_id: ID of the team to view members for
4948 request: FastAPI request object
4949 page: Page number (1-indexed).
4950 per_page: Items per page.
4951 db: Database session
4952 user: Current authenticated user context
4954 Returns:
4955 HTMLResponse: Rendered unified team members management view
4956 """
4957 if not settings.email_auth_enabled:
4958 response = HTMLResponse(
4959 content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Email authentication is disabled</div>',
4960 status_code=403,
4961 )
4962 response.headers["HX-Retarget"] = "#edit-team-error"
4963 response.headers["HX-Reswap"] = "innerHTML"
4964 return response
4966 try:
4967 # Get root_path from request
4968 root_path = request.scope.get("root_path", "")
4970 # Get current user context for logging and authorization
4971 user_email = get_user_email(user)
4972 LOGGER.info(f"User {user_email} viewing/managing members for team {team_id}")
4974 # First-Party
4975 team_service = TeamManagementService(db)
4976 EmailAuthService(db)
4978 # Get team details
4979 team = await team_service.get_team_by_id(team_id)
4980 if not team:
4981 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
4983 # Check if current user is team owner
4984 current_user_role = await team_service.get_user_role_in_team(user_email, team_id)
4985 is_team_owner = current_user_role == "owner"
4987 # Escape team name to prevent XSS
4988 safe_team_name = html.escape(team.name)
4990 # Build the two-section management interface with form
4991 interface_html = f"""
4992 <div class="mb-4">
4993 <div class="flex justify-between items-center mb-4">
4994 <h3 class="text-lg font-medium text-gray-900 dark:text-white">
4995 Team Members: {safe_team_name}
4996 </h3>
4997 <button onclick="document.getElementById('team-edit-modal').classList.add('hidden')"
4998 class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
4999 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
5000 <path stroke-linecap="round" stroke-linejoin="round"
5001 stroke-width="2" d="M6 18L18 6M6 6l12 12" />
5002 </svg>
5003 </button>
5004 </div>
5006 <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
5007 <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
5008 <h4 class="text-sm font-semibold text-gray-900 dark:text-white">
5009 Manage Team Members • Change roles • Add or remove members
5010 </h4>
5011 </div>
5013 <form id="team-members-form-{team.id}" data-team-id="{team.id}"
5014 hx-post="{root_path}/admin/teams/{team.id}/add-member"
5015 hx-target="#team-edit-modal-content"
5016 hx-swap="innerHTML"
5017 class="px-6 py-4">
5019 <!-- Search box -->
5020 <div class="mb-4">
5021 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search Users</label>
5022 <input
5023 type="text"
5024 id="user-search-{team.id}"
5025 data-team-id="{team.id}"
5026 placeholder="Search by name or email..."
5027 class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
5028 oninput="debouncedServerSideUserSearch('{team.id}', this.value)"
5029 />
5030 </div>
5032 <!-- Current Members Section -->
5033 <div class="mb-6">
5034 <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Members</h5>
5035 <div
5036 id="team-members-container-{team.id}"
5037 class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700"
5038 data-per-page="{per_page}"
5039 hx-get="{root_path}/admin/teams/{team.id}/members/partial?page={page}&per_page={per_page}"
5040 hx-trigger="load delay:100ms"
5041 hx-target="this"
5042 hx-swap="innerHTML"
5043 >
5044 <!-- Current members will be loaded here via HTMX -->
5045 </div>
5046 </div>
5048 <!-- Users to Add Section -->
5049 <div class="mb-4">
5050 <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Users to Add</h5>
5051 <div
5052 id="team-non-members-container-{team.id}"
5053 class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700"
5054 data-per-page="{per_page}"
5055 hx-get="{root_path}/admin/teams/{team.id}/non-members/partial?page=1&per_page={per_page}"
5056 hx-trigger="load delay:200ms"
5057 hx-target="this"
5058 hx-swap="innerHTML"
5059 >
5060 <!-- Non-members will be loaded here via HTMX -->
5061 </div>
5062 </div>
5064 <!-- Submit button (only for team owners) -->
5065 {
5066 ""
5067 if not is_team_owner
5068 else '''
5069 <div class="flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700">
5070 <button type="submit"
5071 class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
5072 Save Changes
5073 </button>
5074 </div>
5075 '''
5076 }
5077 </form>
5078 </div>
5079 </div>
5080 """ # nosec B608 - HTML template f-string, not SQL (uses SQLAlchemy ORM for DB)
5082 response = HTMLResponse(content=interface_html)
5083 # Prevent nginx caching for real-time team member updates
5084 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
5085 response.headers["Pragma"] = "no-cache"
5086 response.headers["Expires"] = "0"
5087 return response
5089 except Exception as e:
5090 LOGGER.error(f"Error viewing team members {team_id}: {e}")
5091 return HTMLResponse(content=f'<div class="text-red-500">Error loading members: {html.escape(str(e))}</div>', status_code=500)
5094@admin_router.get("/teams/{team_id}/members/add")
5095@require_permission("teams.manage_members", allow_admin_bypass=False)
5096async def admin_add_team_members_view(
5097 team_id: str,
5098 request: Request,
5099 db: Session = Depends(get_db),
5100 user=Depends(get_current_user_with_permissions),
5101) -> HTMLResponse:
5102 """Show add members interface with paginated user selector.
5104 Args:
5105 team_id: ID of the team to add members to
5106 request: FastAPI request object
5107 db: Database session
5108 user: Current authenticated user context
5110 Returns:
5111 HTMLResponse: Rendered add members interface
5112 """
5113 if not settings.email_auth_enabled:
5114 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5116 try:
5117 # Get root_path from request
5118 root_path = request.scope.get("root_path", "")
5120 # Get current user context for logging and authorization
5121 user_email = get_user_email(user)
5122 LOGGER.info(f"User {user_email} adding members to team {team_id}")
5124 # First-Party
5125 team_service = TeamManagementService(db)
5127 # Get team details
5128 team = await team_service.get_team_by_id(team_id)
5129 if not team:
5130 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
5132 # Check if current user is team owner
5133 current_user_role = await team_service.get_user_role_in_team(user_email, team_id)
5134 if current_user_role != "owner":
5135 return HTMLResponse(content='<div class="text-red-500">Only team owners can add members</div>', status_code=403)
5137 # Get current team members to exclude from selection
5138 team_members = await team_service.get_team_members(team_id)
5139 member_emails = {team_user.email for team_user, membership in team_members}
5140 # Use orjson to safely serialize the list for JavaScript consumption (prevents XSS/injection)
5141 member_emails_json = orjson.dumps(list(member_emails)).decode() # nosec B105 - JSON array of emails, not password
5143 # Escape team name to prevent XSS
5144 safe_team_name = html.escape(team.name)
5146 # Build add members interface with paginated user selector
5147 add_members_html = f"""
5148 <div class="mb-4">
5149 <div class="flex justify-between items-center mb-4">
5150 <h3 class="text-lg font-medium text-gray-900 dark:text-white">Add Members to: {safe_team_name}</h3>
5151 <div class="flex items-center space-x-2">
5152 <button onclick="loadTeamMembersView('{team.id}')" class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
5153 ← Back to Members
5154 </button>
5155 <button onclick="document.getElementById('team-edit-modal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
5156 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
5157 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
5158 </svg>
5159 </button>
5160 </div>
5161 </div>
5163 <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
5164 <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
5165 <h4 class="text-sm font-semibold text-gray-900 dark:text-white">Select Users to Add</h4>
5166 </div>
5168 <div class="px-6 py-4">
5169 <form id="add-members-form-{team.id}" data-team-id="{team.id}" hx-post="{root_path}/admin/teams/{team.id}/add-member" hx-target="#team-edit-modal-content" hx-swap="innerHTML">
5170 <!-- Search box -->
5171 <div class="mb-4">
5172 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search Users</label>
5173 <input
5174 type="text"
5175 id="user-search-{team.id}"
5176 data-team-id="{team.id}"
5177 data-search-url="{root_path}/admin/users/search"
5178 data-search-limit="10"
5179 placeholder="Search by name or email..."
5180 class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 text-gray-900 dark:text-white"
5181 autocomplete="off"
5182 />
5183 <div id="user-search-loading-{team.id}" class="mt-2 text-sm text-gray-500 dark:text-gray-400 hidden">Searching...</div>
5184 <div id="user-search-results-{team.id}" data-member-emails="{html.escape(member_emails_json)}" class="mt-2"></div>
5185 </div>
5187 <!-- User selector with infinite scroll -->
5188 <div class="mb-4">
5189 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Users</label>
5190 <div
5191 id="user-selector-container-{team.id}"
5192 class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700"
5193 hx-get="{root_path}/admin/users/partial?page=1&per_page=20&render=selector&team_id={team.id}"
5194 hx-trigger="load"
5195 hx-swap="innerHTML"
5196 hx-target="#user-selector-container-{team.id}"
5197 >
5198 <!-- User selector items will be loaded here via HTMX -->
5199 </div>
5200 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
5201 Note: Users already in the team will be ignored if selected.
5202 </p>
5203 </div>
5205 <!-- Action buttons -->
5206 <div class="flex justify-between items-center">
5207 <div id="selected-count-{team.id}" class="text-sm text-gray-600 dark:text-gray-400">
5208 No users selected
5209 </div>
5210 <button
5211 type="submit"
5212 class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
5213 >
5214 Add Selected Members
5215 </button>
5216 </div>
5217 </form>
5218 </div>
5219 </div>
5220 </div>
5221 """ # nosec B608 - HTML template f-string, not SQL (uses SQLAlchemy ORM for DB)
5223 return HTMLResponse(content=add_members_html)
5225 except Exception as e:
5226 LOGGER.error(f"Error loading add members view for team {team_id}: {e}")
5227 return HTMLResponse(content=f'<div class="text-red-500">Error loading add members view: {html.escape(str(e))}</div>', status_code=500)
5230@admin_router.get("/teams/{team_id}/edit")
5231@require_permission("teams.update", allow_admin_bypass=False)
5232async def admin_get_team_edit(
5233 team_id: str,
5234 _request: Request,
5235 db: Session = Depends(get_db),
5236 _user=Depends(get_current_user_with_permissions),
5237) -> HTMLResponse:
5238 """Get team edit form via admin UI.
5240 Args:
5241 team_id: ID of the team to edit
5242 db: Database session
5244 Returns:
5245 HTMLResponse: Rendered team edit form
5246 """
5247 if not settings.email_auth_enabled:
5248 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5250 try:
5251 # Get root path for URL construction
5252 root_path = _request.scope.get("root_path", "") if _request else ""
5253 team_service = TeamManagementService(db)
5255 team = await team_service.get_team_by_id(team_id)
5256 if not team:
5257 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
5259 safe_team_name = html.escape(team.name, quote=True)
5260 safe_description = html.escape(team.description or "")
5261 edit_form = rf"""
5262 <div class="space-y-4">
5263 <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Team</h3>
5264 <div id="edit-team-error"></div>
5265 <form method="post" action="{root_path}/admin/teams/{team_id}/update" hx-post="{root_path}/admin/teams/{team_id}/update" hx-target="#edit-team-error" hx-swap="innerHTML" class="space-y-4" data-team-validation="true">
5266 <div>
5267 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
5268 <input type="text" name="name" value="{safe_team_name}" required
5269 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">
5270 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Letters, numbers, spaces, underscores, periods, and dashes only</p>
5271 </div>
5272 <div>
5273 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</label>
5274 <input type="text" name="slug" value="{team.slug}" readonly
5275 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white">
5276 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Slug cannot be changed</p>
5277 </div>
5278 <div>
5279 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
5280 <textarea name="description" rows="3"
5281 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">{safe_description}</textarea>
5282 </div>
5283 <div>
5284 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Visibility</label>
5285 <select name="visibility"
5286 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">
5287 <option value="private" {"selected" if team.visibility == "private" else ""}>Private</option>
5288 <option value="public" {"selected" if team.visibility == "public" else ""}>Public</option>
5289 </select>
5290 </div>
5291 <div class="flex justify-end space-x-3">
5292 <button type="button" onclick="hideTeamEditModal()"
5293 class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
5294 Cancel
5295 </button>
5296 <button type="submit"
5297 class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
5298 Update Team
5299 </button>
5300 </div>
5301 </form>
5302 </div>
5303 """
5304 return HTMLResponse(content=edit_form)
5306 except Exception as e:
5307 LOGGER.error(f"Error getting team edit form for {team_id}: {e}")
5308 return HTMLResponse(content=f'<div class="text-red-500">Error loading team: {html.escape(str(e))}</div>', status_code=500)
5311@admin_router.post("/teams/{team_id}/update")
5312@require_permission("teams.update", allow_admin_bypass=False)
5313async def admin_update_team(
5314 team_id: str,
5315 request: Request,
5316 db: Session = Depends(get_db),
5317 user=Depends(get_current_user_with_permissions),
5318) -> Response:
5319 """Update team via admin UI.
5321 Args:
5322 team_id: ID of the team to update
5323 request: FastAPI request object
5324 db: Database session
5325 user: Current authenticated user context
5327 Returns:
5328 Response: Result of team update operation
5329 """
5330 # Ensure root_path is available for URL construction in all branches
5331 root_path = request.scope.get("root_path", "") if request else ""
5333 if not settings.email_auth_enabled:
5334 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5336 try:
5337 team_service = TeamManagementService(db)
5339 form = await request.form()
5340 name_val = form.get("name")
5341 desc_val = form.get("description")
5342 vis_val = form.get("visibility", "private")
5343 # Trim before presence check for consistent error messages
5344 name = name_val.strip() if isinstance(name_val, str) else None
5345 description = desc_val.strip() if isinstance(desc_val, str) and desc_val.strip() != "" else None
5346 visibility = vis_val if isinstance(vis_val, str) else "private"
5348 if not name:
5349 is_htmx = request.headers.get("HX-Request") == "true"
5350 if is_htmx:
5351 response = HTMLResponse(
5352 content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Team name is required</div>',
5353 status_code=400,
5354 )
5355 response.headers["HX-Retarget"] = "#edit-team-error"
5356 response.headers["HX-Reswap"] = "innerHTML"
5357 return response
5358 error_msg = urllib.parse.quote("Team name is required")
5359 return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)
5361 # Validate name and description for XSS (same validation as schema)
5362 if not re.match(settings.validation_name_pattern, name):
5363 is_htmx = request.headers.get("HX-Request") == "true"
5364 if is_htmx:
5365 response = HTMLResponse(
5366 content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Team name can only contain letters, numbers, spaces, underscores, periods, and dashes</div>',
5367 status_code=400,
5368 )
5369 response.headers["HX-Retarget"] = "#edit-team-error"
5370 response.headers["HX-Reswap"] = "innerHTML"
5371 return response
5372 error_msg = urllib.parse.quote("Team name contains invalid characters")
5373 return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)
5375 try:
5376 SecurityValidator.validate_no_xss(name, "Team name")
5377 if re.search(SecurityValidator.DANGEROUS_JS_PATTERN, name, re.IGNORECASE):
5378 raise ValueError("Team name contains script patterns that may cause security issues")
5379 if description:
5380 SecurityValidator.validate_no_xss(description, "Team description")
5381 if re.search(SecurityValidator.DANGEROUS_JS_PATTERN, description, re.IGNORECASE):
5382 raise ValueError("Team description contains script patterns that may cause security issues")
5383 except ValueError as ve:
5384 is_htmx = request.headers.get("HX-Request") == "true"
5385 if is_htmx:
5386 response = HTMLResponse(
5387 content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">{html.escape(str(ve))}</div>',
5388 status_code=400,
5389 )
5390 response.headers["HX-Retarget"] = "#edit-team-error"
5391 response.headers["HX-Reswap"] = "innerHTML"
5392 return response
5393 error_msg = urllib.parse.quote(str(ve))
5394 return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)
5396 # Update team
5397 user_email = getattr(user, "email", None) or str(user)
5398 await team_service.update_team(team_id=team_id, name=name, description=description, visibility=visibility, updated_by=user_email)
5400 # Check if this is an HTMX request
5401 is_htmx = request.headers.get("HX-Request") == "true"
5403 if is_htmx:
5404 # Return success message with auto-close and refresh for HTMX
5405 success_html = """
5406 <div class="text-green-500 text-center p-4">
5407 <p>Team updated successfully</p>
5408 </div>
5409 """
5410 response = HTMLResponse(content=success_html)
5411 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"closeTeamEditModal": True, "refreshUnifiedTeamsList": True, "delayMs": 1500}}).decode()
5412 return response
5413 # For regular form submission, redirect to admin page with teams section
5414 return RedirectResponse(url=f"{root_path}/admin/#teams", status_code=303)
5416 except Exception as e:
5417 LOGGER.error(f"Error updating team {team_id}: {e}")
5419 # Check if this is an HTMX request for error handling too
5420 is_htmx = request.headers.get("HX-Request") == "true"
5422 if is_htmx:
5423 return HTMLResponse(content=f'<div class="text-red-500">Error updating team: {html.escape(str(e))}</div>', status_code=400)
5424 # For regular form submission, redirect to admin page with error parameter
5425 error_msg = urllib.parse.quote(f"Error updating team: {str(e)}")
5426 return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)
5429@admin_router.delete("/teams/{team_id}")
5430@require_permission("teams.delete", allow_admin_bypass=False)
5431async def admin_delete_team(
5432 team_id: str,
5433 _request: Request,
5434 db: Session = Depends(get_db),
5435 user=Depends(get_current_user_with_permissions),
5436) -> HTMLResponse:
5437 """Delete team via admin UI.
5439 Args:
5440 team_id: ID of the team to delete
5441 db: Database session
5442 user: Current authenticated user context
5444 Returns:
5445 HTMLResponse: Success message or error response
5446 """
5447 if not settings.email_auth_enabled:
5448 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5450 try:
5451 team_service = TeamManagementService(db)
5453 # Get team name for success message
5454 team = await team_service.get_team_by_id(team_id)
5455 team_name = team.name if team else "Unknown"
5457 # Delete team (get user email from JWT payload)
5458 user_email = get_user_email(user)
5459 await team_service.delete_team(team_id, deleted_by=user_email)
5461 # Return success message with script to refresh teams list
5462 safe_team_name = html.escape(team_name)
5463 success_html = f"""
5464 <div class="text-green-500 text-center p-4">
5465 <p>Team "{safe_team_name}" deleted successfully</p>
5466 </div>
5467 """
5468 response = HTMLResponse(content=success_html)
5469 # Prevent nginx caching for real-time updates
5470 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
5471 response.headers["Pragma"] = "no-cache"
5472 response.headers["Expires"] = "0"
5473 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "delayMs": 1000}}).decode()
5474 return response
5476 except Exception as e:
5477 LOGGER.error(f"Error deleting team {team_id}: {e}")
5478 return HTMLResponse(content=f'<div class="text-red-500">Error deleting team: {html.escape(str(e))}</div>', status_code=400)
5481@admin_router.post("/teams/{team_id}/add-member")
5482@require_permission("teams.manage_members", allow_admin_bypass=False)
5483async def admin_add_team_members(
5484 team_id: str,
5485 request: Request,
5486 db: Session = Depends(get_db),
5487 user=Depends(get_current_user_with_permissions),
5488) -> HTMLResponse:
5489 """Add member(s) to team via admin UI.
5491 Supports both single user (user_email field) and multiple users (associatedUsers field).
5493 Args:
5494 team_id: ID of the team to add member(s) to
5495 request: FastAPI request object
5496 db: Database session
5497 user: Current authenticated user context
5499 Returns:
5500 HTMLResponse: Success message or error response
5501 """
5502 if not settings.email_auth_enabled:
5503 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5505 try:
5506 # First-Party
5507 team_service = TeamManagementService(db)
5508 auth_service = EmailAuthService(db)
5510 # Check if team exists and validate visibility
5511 team = await team_service.get_team_by_id(team_id)
5512 if not team:
5513 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
5515 # For private teams, only team owners can add members directly
5516 user_email_from_jwt = get_user_email(user)
5517 if team.visibility == "private":
5518 user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id)
5519 if user_role != "owner":
5520 return HTMLResponse(content='<div class="text-red-500">Only team owners can add members to private teams. Use the invitation system instead.</div>', status_code=403)
5522 form = await request.form()
5524 # Get loaded members - these are members that were visible in the form (for safe removal with pagination)
5525 loaded_members_list = form.getlist("loadedMembers")
5526 loaded_members = {email.strip() for email in loaded_members_list if isinstance(email, str) and email.strip()}
5528 # Check if this is single user or multiple users
5529 single_user_email = form.get("user_email")
5530 multiple_user_emails = form.getlist("associatedUsers")
5532 # Determine which mode we're in
5533 if single_user_email:
5534 # Single user mode (legacy form) - get single role
5535 user_emails = [single_user_email] if isinstance(single_user_email, str) else []
5536 default_role = form.get("role", "member")
5537 default_role = default_role if isinstance(default_role, str) else "member"
5538 elif multiple_user_emails:
5539 # Multiple users mode (new paginated selector)
5540 seen = set()
5541 user_emails = []
5542 for email in multiple_user_emails:
5543 if not isinstance(email, str):
5544 continue
5545 cleaned = email.strip()
5546 if not cleaned or cleaned in seen:
5547 continue
5548 seen.add(cleaned)
5549 user_emails.append(cleaned)
5550 default_role = "member" # Default if no per-user role specified
5551 else:
5552 return HTMLResponse(content='<div class="text-red-500">No users selected</div>', status_code=400)
5554 # Get current team members
5555 team_members = await team_service.get_team_members(team_id)
5556 existing_member_emails = {team_user.email for team_user, membership in team_members}
5558 # Build a map of existing member roles
5559 existing_member_roles = {}
5560 owner_count = team_service.count_team_owners(team_id)
5561 for team_user, membership in team_members:
5562 email = team_user.email
5563 is_last_owner = membership.role == "owner" and owner_count == 1
5564 existing_member_roles[email] = {"role": membership.role, "is_last_owner": is_last_owner}
5566 # Track results
5567 added = []
5568 updated = []
5569 removed = []
5570 errors = []
5572 # Process submitted users (checked boxes)
5573 submitted_user_emails = set(user_emails)
5575 # 1. Handle additions and updates for checked users
5576 for user_email in user_emails:
5577 user_email = user_email.strip()
5578 if not user_email:
5579 continue
5581 try:
5582 # Check if user exists
5583 target_user = await auth_service.get_user_by_email(user_email)
5584 if not target_user:
5585 errors.append(f"{user_email} (user not found)")
5586 continue
5588 # Get per-user role from form (format: role_<url-encoded-email>)
5589 encoded_email = urllib.parse.quote(user_email, safe="")
5590 user_role_key = f"role_{encoded_email}"
5591 user_role_val = form.get(user_role_key, default_role)
5592 user_role = user_role_val if isinstance(user_role_val, str) else default_role
5594 if user_email in existing_member_emails:
5595 # User is already a member - check if role changed
5596 current_role = existing_member_roles[user_email]["role"]
5597 if current_role != user_role:
5598 # Don't allow changing role of last owner
5599 if existing_member_roles[user_email]["is_last_owner"]:
5600 errors.append(f"{user_email} (cannot change role of last owner)")
5601 continue
5602 # Update role
5603 await team_service.update_member_role(team_id=team_id, user_email=user_email, new_role=user_role, updated_by=user_email_from_jwt)
5604 updated.append(f"{user_email} (role: {user_role})")
5605 else:
5606 # New member - add them
5607 await team_service.add_member_to_team(team_id=team_id, user_email=user_email, role=user_role, invited_by=user_email_from_jwt)
5608 added.append(user_email)
5610 except Exception as member_error:
5611 LOGGER.error(f"Error processing {user_email} for team {team_id}: {member_error}")
5612 errors.append(f"{user_email} ({str(member_error)})")
5614 # 2. Handle removals - only remove members who were LOADED in the form AND unchecked
5615 # This prevents accidentally removing members from pages that weren't loaded yet (infinite scroll safety)
5616 for existing_email in existing_member_emails:
5617 # Only consider removal if the member was visible in the form (in loadedMembers)
5618 if existing_email not in loaded_members:
5619 continue # Member wasn't loaded in form, skip (safe for pagination)
5620 if existing_email in submitted_user_emails:
5621 continue # Member is checked, don't remove
5623 member_info = existing_member_roles.get(existing_email, {})
5625 # Validate removal is allowed - server-side protection
5626 # Current user cannot be removed
5627 if existing_email == user_email_from_jwt:
5628 errors.append(f"{existing_email} (cannot remove yourself)")
5629 continue
5630 # Last owner cannot be removed
5631 if member_info.get("is_last_owner", False):
5632 errors.append(f"{existing_email} (cannot remove last owner)")
5633 continue
5635 # This member was unchecked and removal is allowed - remove them
5636 try:
5637 await team_service.remove_member_from_team(team_id=team_id, user_email=existing_email, removed_by=user_email_from_jwt)
5638 removed.append(existing_email)
5639 except Exception as removal_error:
5640 LOGGER.error(f"Error removing {existing_email} from team {team_id}: {removal_error}")
5641 errors.append(f"{existing_email} (removal failed: {str(removal_error)})")
5643 # Build result message
5644 result_parts = []
5645 if added:
5646 result_parts.append(f'<p class="text-green-600 dark:text-green-400">✓ Added {len(added)} member(s)</p>')
5647 if updated:
5648 result_parts.append(f'<p class="text-blue-600 dark:text-blue-400">↻ Updated {len(updated)} member(s)</p>')
5649 if removed:
5650 result_parts.append(f'<p class="text-orange-600 dark:text-orange-400">− Removed {len(removed)} member(s)</p>')
5651 if errors:
5652 result_parts.append(f'<p class="text-red-600 dark:text-red-400">✗ {len(errors)} error(s)</p>')
5653 for error in errors[:5]: # Show first 5 errors
5654 result_parts.append(f'<p class="text-xs text-red-500 dark:text-red-400 ml-4">• {error}</p>')
5655 if len(errors) > 5:
5656 result_parts.append(f'<p class="text-xs text-red-500 dark:text-red-400 ml-4">... and {len(errors) - 5} more</p>')
5658 if not result_parts:
5659 result_parts.append('<p class="text-gray-600 dark:text-gray-400">No changes made</p>')
5661 result_html = "\n".join(result_parts)
5663 # Return success message and close modal
5664 success_html = f"""
5665 <div class="text-center p-4">
5666 {result_html}
5667 </div>
5668 <script>
5669 // Close modal after showing success message briefly
5670 setTimeout(() => {{
5671 const modal = document.getElementById('team-edit-modal');
5672 if (modal) {{
5673 modal.classList.add('hidden');
5674 }}
5675 }}, 1000);
5676 </script>
5677 """
5678 response = HTMLResponse(content=success_html)
5680 # Prevent nginx caching for real-time updates
5681 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
5682 response.headers["Pragma"] = "no-cache"
5683 response.headers["Expires"] = "0"
5685 # Trigger refresh of teams list (but don't reopen modal)
5686 response.headers["HX-Trigger"] = orjson.dumps(
5687 {
5688 "adminTeamAction": {
5689 "teamId": team_id,
5690 "refreshUnifiedTeamsList": True,
5691 }
5692 }
5693 ).decode()
5694 return response
5696 except Exception as e:
5697 LOGGER.error(f"Error adding member(s) to team {team_id}: {e}")
5698 return HTMLResponse(content=f'<div class="text-red-500">Error adding member(s): {html.escape(str(e))}</div>', status_code=400)
5701@admin_router.post("/teams/{team_id}/update-member-role")
5702@require_permission("teams.manage_members", allow_admin_bypass=False)
5703async def admin_update_team_member_role(
5704 team_id: str,
5705 request: Request,
5706 db: Session = Depends(get_db),
5707 user=Depends(get_current_user_with_permissions),
5708) -> HTMLResponse:
5709 """Update team member role via admin UI.
5711 Args:
5712 team_id: ID of the team containing the member
5713 request: FastAPI request object
5714 db: Database session
5715 user: Current authenticated user context
5717 Returns:
5718 HTMLResponse: Success message or error response
5719 """
5720 if not settings.email_auth_enabled:
5721 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5723 try:
5724 team_service = TeamManagementService(db)
5726 # Check if team exists and validate user permissions
5727 team = await team_service.get_team_by_id(team_id)
5728 if not team:
5729 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
5731 # Only team owners can modify member roles
5732 user_email_from_jwt = get_user_email(user)
5733 user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id)
5734 if user_role != "owner":
5735 return HTMLResponse(content='<div class="text-red-500">Only team owners can modify member roles</div>', status_code=403)
5737 form = await request.form()
5738 ue_val = form.get("user_email")
5739 nr_val = form.get("role", "member")
5740 user_email = ue_val if isinstance(ue_val, str) else None
5741 new_role = nr_val if isinstance(nr_val, str) else "member"
5743 if not user_email:
5744 return HTMLResponse(content='<div class="text-red-500">User email is required</div>', status_code=400)
5746 if not new_role:
5747 return HTMLResponse(content='<div class="text-red-500">Role is required</div>', status_code=400)
5749 # Update member role
5750 await team_service.update_member_role(team_id=team_id, user_email=user_email, new_role=new_role, updated_by=user_email_from_jwt)
5752 # Return success message with auto-close and refresh
5753 success_html = f"""
5754 <div class="text-green-500 text-center p-4">
5755 <p>Role updated successfully for {user_email}</p>
5756 </div>
5757 """
5758 response = HTMLResponse(content=success_html)
5759 response.headers["HX-Trigger"] = orjson.dumps(
5760 {
5761 "adminTeamAction": {
5762 "teamId": team_id,
5763 "refreshTeamMembers": True,
5764 "refreshUnifiedTeamsList": True,
5765 "closeRoleModal": True,
5766 "delayMs": 1000,
5767 }
5768 }
5769 ).decode()
5770 return response
5772 except Exception as e:
5773 LOGGER.error(f"Error updating member role in team {team_id}: {e}")
5774 return HTMLResponse(content=f'<div class="text-red-500">Error updating role: {html.escape(str(e))}</div>', status_code=400)
5777@admin_router.post("/teams/{team_id}/remove-member")
5778@require_permission("teams.manage_members", allow_admin_bypass=False)
5779async def admin_remove_team_member(
5780 team_id: str,
5781 request: Request,
5782 db: Session = Depends(get_db),
5783 user=Depends(get_current_user_with_permissions),
5784) -> HTMLResponse:
5785 """Remove member from team via admin UI.
5787 Args:
5788 team_id: ID of the team to remove member from
5789 request: FastAPI request object
5790 db: Database session
5791 user: Current authenticated user context
5793 Returns:
5794 HTMLResponse: Success message or error response
5795 """
5796 if not settings.email_auth_enabled:
5797 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5799 try:
5800 team_service = TeamManagementService(db)
5802 # Check if team exists and validate user permissions
5803 team = await team_service.get_team_by_id(team_id)
5804 if not team:
5805 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
5807 # Only team owners can remove members
5808 user_email_from_jwt = get_user_email(user)
5809 user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id)
5810 if user_role != "owner":
5811 return HTMLResponse(content='<div class="text-red-500">Only team owners can remove members</div>', status_code=403)
5813 form = await request.form()
5814 ue_val = form.get("user_email")
5815 user_email = ue_val if isinstance(ue_val, str) else None
5817 if not user_email:
5818 return HTMLResponse(content='<div class="text-red-500">User email is required</div>', status_code=400)
5820 # Remove member from team
5822 try:
5823 success = await team_service.remove_member_from_team(team_id=team_id, user_email=user_email, removed_by=user_email_from_jwt)
5824 if not success:
5825 return HTMLResponse(content='<div class="text-red-500">Failed to remove member from team</div>', status_code=400)
5826 except ValueError as e:
5827 # Handle specific business logic errors (like last owner)
5828 return HTMLResponse(content=f'<div class="text-red-500">{html.escape(str(e))}</div>', status_code=400)
5830 # Return success message with script to refresh modal
5831 success_html = f"""
5832 <div class="text-green-500 text-center p-4">
5833 <p>Member {user_email} removed successfully</p>
5834 </div>
5835 """
5836 response = HTMLResponse(content=success_html)
5837 response.headers["HX-Trigger"] = orjson.dumps(
5838 {
5839 "adminTeamAction": {
5840 "teamId": team_id,
5841 "refreshTeamMembers": True,
5842 "refreshUnifiedTeamsList": True,
5843 "delayMs": 1000,
5844 }
5845 }
5846 ).decode()
5847 return response
5849 except Exception as e:
5850 LOGGER.error(f"Error removing member from team {team_id}: {e}")
5851 return HTMLResponse(content=f'<div class="text-red-500">Error removing member: {html.escape(str(e))}</div>', status_code=400)
5854@admin_router.post("/teams/{team_id}/leave")
5855@require_permission("teams.join", allow_admin_bypass=False) # Users who can join can also leave
5856async def admin_leave_team(
5857 team_id: str,
5858 request: Request, # pylint: disable=unused-argument
5859 db: Session = Depends(get_db),
5860 user=Depends(get_current_user_with_permissions),
5861) -> HTMLResponse:
5862 """Leave a team via admin UI.
5864 Args:
5865 team_id: ID of the team to leave
5866 request: FastAPI request object
5867 db: Database session
5868 user: Current authenticated user context
5870 Returns:
5871 HTMLResponse: Success message or error response
5872 """
5873 if not settings.email_auth_enabled:
5874 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5876 try:
5877 team_service = TeamManagementService(db)
5879 # Check if team exists
5880 team = await team_service.get_team_by_id(team_id)
5881 if not team:
5882 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
5884 # Get current user email
5885 user_email = get_user_email(user)
5887 # Check if user is a member of the team
5888 user_role = await team_service.get_user_role_in_team(user_email, team_id)
5889 if not user_role:
5890 return HTMLResponse(content='<div class="text-red-500">You are not a member of this team</div>', status_code=400)
5892 # Prevent leaving personal teams
5893 if team.is_personal:
5894 return HTMLResponse(content='<div class="text-red-500">Cannot leave your personal team</div>', status_code=400)
5896 # Check if user is the last owner (use SQL COUNT instead of loading all members)
5897 if user_role == "owner":
5898 owner_count = team_service.count_team_owners(team_id)
5899 if owner_count <= 1:
5900 return HTMLResponse(content='<div class="text-red-500">Cannot leave team as the last owner. Transfer ownership or delete the team instead.</div>', status_code=400)
5902 # Remove user from team
5903 success = await team_service.remove_member_from_team(team_id=team_id, user_email=user_email, removed_by=user_email)
5904 if not success:
5905 return HTMLResponse(content='<div class="text-red-500">Failed to leave team</div>', status_code=400)
5907 # Return success message with redirect
5908 success_html = """
5909 <div class="text-green-500 text-center p-4">
5910 <p>Successfully left the team</p>
5911 </div>
5912 """
5913 response = HTMLResponse(content=success_html)
5914 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "closeAllModals": True, "delayMs": 1500}}).decode()
5915 return response
5917 except Exception as e:
5918 LOGGER.error(f"Error leaving team {team_id}: {e}")
5919 return HTMLResponse(content=f'<div class="text-red-500">Error leaving team: {html.escape(str(e))}</div>', status_code=400)
5922# ============================================================================ #
5923# TEAM JOIN REQUEST ADMIN ROUTES #
5924# ============================================================================ #
5927@admin_router.post("/teams/{team_id}/join-request")
5928@require_permission("teams.join", allow_admin_bypass=False)
5929async def admin_create_join_request(
5930 team_id: str,
5931 request: Request,
5932 db: Session = Depends(get_db),
5933 user=Depends(get_current_user_with_permissions),
5934) -> HTMLResponse:
5935 """Create a join request for a team via admin UI.
5937 Args:
5938 team_id: ID of the team to request to join
5939 request: FastAPI request object
5940 db: Database session
5941 user: Authenticated user
5943 Returns:
5944 HTML response with success message or error
5945 """
5946 if not getattr(settings, "email_auth_enabled", False):
5947 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
5949 try:
5950 team_service = TeamManagementService(db)
5951 user_email = get_user_email(user)
5953 # Get team to verify it's public
5954 team = await team_service.get_team_by_id(team_id)
5955 if not team:
5956 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
5958 if team.visibility != "public":
5959 return HTMLResponse(content='<div class="text-red-500">Can only request to join public teams</div>', status_code=400)
5961 # Check if user is already a member
5962 user_role = await team_service.get_user_role_in_team(user_email, team_id)
5963 if user_role:
5964 return HTMLResponse(content='<div class="text-red-500">You are already a member of this team</div>', status_code=400)
5966 # Check if user already has a pending request
5967 existing_requests = await team_service.get_user_join_requests(user_email, team_id)
5968 pending_request = next((req for req in existing_requests if req.status == "pending"), None)
5969 if pending_request:
5970 return HTMLResponse(
5971 content=f"""
5972 <div class="text-yellow-600">
5973 <p>You already have a pending request to join this team.</p>
5974 <button onclick="cancelJoinRequest('{team_id}', '{pending_request.id}')"
5975 class="mt-2 px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
5976 Cancel Request
5977 </button>
5978 </div>
5979 """,
5980 status_code=200,
5981 )
5983 # Get form data for optional message
5984 form = await request.form()
5985 msg_val = form.get("message", "")
5986 message = msg_val if isinstance(msg_val, str) else ""
5988 # Create join request
5989 join_request = await team_service.create_join_request(team_id=team_id, user_email=user_email, message=message)
5991 return HTMLResponse(
5992 content=f"""
5993 <div class="text-green-600">
5994 <p>Join request submitted successfully!</p>
5995 <button onclick="cancelJoinRequest('{team_id}', '{join_request.id}')"
5996 class="mt-2 px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
5997 Cancel Request
5998 </button>
5999 </div>
6000 """,
6001 status_code=201,
6002 )
6004 except Exception as e:
6005 LOGGER.error(f"Error creating join request for team {team_id}: {e}")
6006 return HTMLResponse(content=f'<div class="text-red-500">Error creating join request: {html.escape(str(e))}</div>', status_code=400)
6009@admin_router.delete("/teams/{team_id}/join-request/{request_id}")
6010@require_permission("teams.join", allow_admin_bypass=False)
6011async def admin_cancel_join_request(
6012 team_id: str,
6013 request_id: str,
6014 db: Session = Depends(get_db),
6015 user=Depends(get_current_user_with_permissions),
6016) -> HTMLResponse:
6017 """Cancel a join request via admin UI.
6019 Args:
6020 team_id: ID of the team
6021 request_id: ID of the join request to cancel
6022 db: Database session
6023 user: Authenticated user
6025 Returns:
6026 HTML response with updated button state
6027 """
6028 if not getattr(settings, "email_auth_enabled", False):
6029 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
6031 try:
6032 team_service = TeamManagementService(db)
6033 user_email = get_user_email(user)
6035 # Cancel the join request
6036 success = await team_service.cancel_join_request(request_id, user_email)
6037 if not success:
6038 return HTMLResponse(content='<div class="text-red-500">Failed to cancel join request</div>', status_code=400)
6040 # Return the "Request to Join" button with HX-Trigger for list refresh
6041 response = HTMLResponse(
6042 content=f"""
6043 <button data-team-id="{team_id}" data-team-name="Team" onclick="requestToJoinTeamSafe(this)"
6044 class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
6045 Request to Join
6046 </button>
6047 """,
6048 status_code=200,
6049 )
6050 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "delayMs": 1000}}).decode()
6051 return response
6053 except Exception as e:
6054 LOGGER.error(f"Error canceling join request {request_id}: {e}")
6055 return HTMLResponse(content=f'<div class="text-red-500">Error canceling join request: {html.escape(str(e))}</div>', status_code=400)
6058@admin_router.get("/teams/{team_id}/join-requests")
6059@require_permission("teams.manage_members", allow_admin_bypass=False)
6060async def admin_list_join_requests(
6061 team_id: str,
6062 request: Request,
6063 db: Session = Depends(get_db),
6064 user=Depends(get_current_user_with_permissions),
6065) -> HTMLResponse:
6066 """List join requests for a team via admin UI.
6068 Args:
6069 team_id: ID of the team
6070 request: FastAPI request object
6071 db: Database session
6072 user: Authenticated user
6074 Returns:
6075 HTML response with join requests list
6076 """
6077 if not getattr(settings, "email_auth_enabled", False):
6078 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
6080 try:
6081 team_service = TeamManagementService(db)
6082 user_email = get_user_email(user)
6083 request.scope.get("root_path", "")
6085 # Get team and verify ownership
6086 team = await team_service.get_team_by_id(team_id)
6087 if not team:
6088 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
6090 user_role = await team_service.get_user_role_in_team(user_email, team_id)
6091 if user_role != "owner":
6092 return HTMLResponse(content='<div class="text-red-500">Only team owners can view join requests</div>', status_code=403)
6094 # Get join requests
6095 join_requests = await team_service.list_join_requests(team_id)
6097 if not join_requests:
6098 return HTMLResponse(
6099 content="""
6100 <div class="text-center py-8">
6101 <p class="text-gray-500 dark:text-gray-400">No pending join requests</p>
6102 </div>
6103 """,
6104 status_code=200,
6105 )
6107 requests_html = ""
6108 for req in join_requests:
6109 safe_email = html.escape(req.user_email)
6110 safe_message = html.escape(req.message) if req.message else ""
6111 safe_status = html.escape(req.status.upper())
6112 requests_html += f"""
6113 <div class="flex justify-between items-center p-4 border border-gray-200 dark:border-gray-600 rounded-lg mb-3">
6114 <div>
6115 <p class="font-medium text-gray-900 dark:text-white">{safe_email}</p>
6116 <p class="text-sm text-gray-600 dark:text-gray-400">Requested: {req.requested_at.strftime("%Y-%m-%d %H:%M") if req.requested_at else "Unknown"}</p>
6117 {f'<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Message: {safe_message}</p>' if req.message else ""}
6118 <span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300">{safe_status}</span>
6119 </div>
6120 <div class="flex gap-2">
6121 <button onclick="approveJoinRequest('{team_id}', '{req.id}')"
6122 class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
6123 Approve
6124 </button>
6125 <button onclick="rejectJoinRequest('{team_id}', '{req.id}')"
6126 class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
6127 Reject
6128 </button>
6129 </div>
6130 </div>
6131 """
6133 safe_team_name = html.escape(team.name)
6134 return HTMLResponse(
6135 content=f"""
6136 <div class="space-y-4">
6137 <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Join Requests for {safe_team_name}</h3>
6138 {requests_html}
6139 </div>
6140 """,
6141 status_code=200,
6142 )
6144 except Exception as e:
6145 LOGGER.error(f"Error listing join requests for team {team_id}: {e}")
6146 return HTMLResponse(content=f'<div class="text-red-500">Error loading join requests: {html.escape(str(e))}</div>', status_code=400)
6149@admin_router.post("/teams/{team_id}/join-requests/{request_id}/approve")
6150@require_permission("teams.manage_members", allow_admin_bypass=False)
6151async def admin_approve_join_request(
6152 team_id: str,
6153 request_id: str,
6154 db: Session = Depends(get_db),
6155 user=Depends(get_current_user_with_permissions),
6156) -> HTMLResponse:
6157 """Approve a join request via admin UI.
6159 Args:
6160 team_id: ID of the team
6161 request_id: ID of the join request to approve
6162 db: Database session
6163 user: Authenticated user
6165 Returns:
6166 HTML response with success message
6167 """
6168 if not getattr(settings, "email_auth_enabled", False):
6169 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
6171 try:
6172 team_service = TeamManagementService(db)
6173 user_email = get_user_email(user)
6175 # Verify team ownership
6176 user_role = await team_service.get_user_role_in_team(user_email, team_id)
6177 if user_role != "owner":
6178 return HTMLResponse(content='<div class="text-red-500">Only team owners can approve join requests</div>', status_code=403)
6180 # Approve join request
6181 member = await team_service.approve_join_request(request_id, approved_by=user_email)
6182 if not member:
6183 return HTMLResponse(content='<div class="text-red-500">Join request not found</div>', status_code=404)
6185 response = HTMLResponse(
6186 content=f"""
6187 <div class="text-green-600 text-center p-4">
6188 <p>Join request approved! {member.user_email} is now a team member.</p>
6189 </div>
6190 """,
6191 status_code=200,
6192 )
6193 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"teamId": team_id, "refreshJoinRequests": True, "delayMs": 1000}}).decode()
6194 return response
6196 except Exception as e:
6197 LOGGER.error(f"Error approving join request {request_id}: {e}")
6198 return HTMLResponse(content=f'<div class="text-red-500">Error approving join request: {html.escape(str(e))}</div>', status_code=400)
6201@admin_router.post("/teams/{team_id}/join-requests/{request_id}/reject")
6202@require_permission("teams.manage_members", allow_admin_bypass=False)
6203async def admin_reject_join_request(
6204 team_id: str,
6205 request_id: str,
6206 db: Session = Depends(get_db),
6207 user=Depends(get_current_user_with_permissions),
6208) -> HTMLResponse:
6209 """Reject a join request via admin UI.
6211 Args:
6212 team_id: ID of the team
6213 request_id: ID of the join request to reject
6214 db: Database session
6215 user: Authenticated user
6217 Returns:
6218 HTML response with success message
6219 """
6220 if not getattr(settings, "email_auth_enabled", False):
6221 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
6223 try:
6224 team_service = TeamManagementService(db)
6225 user_email = get_user_email(user)
6227 # Verify team ownership
6228 user_role = await team_service.get_user_role_in_team(user_email, team_id)
6229 if user_role != "owner":
6230 return HTMLResponse(content='<div class="text-red-500">Only team owners can reject join requests</div>', status_code=403)
6232 # Reject join request
6233 success = await team_service.reject_join_request(request_id, rejected_by=user_email)
6234 if not success:
6235 return HTMLResponse(content='<div class="text-red-500">Join request not found</div>', status_code=404)
6237 response = HTMLResponse(
6238 content="""
6239 <div class="text-green-600 text-center p-4">
6240 <p>Join request rejected.</p>
6241 </div>
6242 """,
6243 status_code=200,
6244 )
6245 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"teamId": team_id, "refreshJoinRequests": True, "delayMs": 1000}}).decode()
6246 return response
6248 except Exception as e:
6249 LOGGER.error(f"Error rejecting join request {request_id}: {e}")
6250 return HTMLResponse(content=f'<div class="text-red-500">Error rejecting join request: {html.escape(str(e))}</div>', status_code=400)
6253# ============================================================================ #
6254# USER MANAGEMENT ADMIN ROUTES #
6255# ============================================================================ #
6258def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, root_path: str) -> str:
6259 """Render a single user card HTML snippet matching the users list template.
6261 Args:
6262 user_obj: User record to render.
6263 current_user_email: Email of the current user for "You" badge logic.
6264 admin_count: Count of active admins to protect the last admin.
6265 root_path: Application root path for HTMX endpoints.
6267 Returns:
6268 HTML snippet for the user card.
6269 """
6270 encoded_email = urllib.parse.quote(user_obj.email, safe="")
6271 display_name = html.escape(user_obj.full_name or "N/A")
6272 safe_email = html.escape(user_obj.email)
6273 auth_provider = html.escape(user_obj.auth_provider or "unknown")
6274 created_at = user_obj.created_at.strftime("%Y-%m-%d %H:%M") if user_obj.created_at else "Unknown"
6276 is_current_user = user_obj.email == current_user_email
6277 is_last_admin = bool(user_obj.is_admin and user_obj.is_active and admin_count == 1)
6278 locked_until = getattr(user_obj, "locked_until", None)
6279 is_locked = bool(locked_until and locked_until > utc_now())
6280 failed_attempts = int(getattr(user_obj, "failed_login_attempts", 0) or 0)
6281 lock_until_text = locked_until.strftime("%Y-%m-%d %H:%M") if locked_until else "N/A"
6283 badges = []
6284 if user_obj.is_admin:
6285 badges.append('<span class="px-2 py-1 text-xs font-semibold bg-purple-100 text-purple-800 rounded-full ' + 'dark:bg-purple-900 dark:text-purple-200">Admin</span>')
6286 if user_obj.is_active:
6287 badges.append('<span class="px-2 py-1 text-xs font-semibold text-green-600 bg-gray-100 dark:bg-gray-700 rounded-full">Active</span>')
6288 else:
6289 badges.append('<span class="px-2 py-1 text-xs font-semibold text-red-600 bg-gray-100 dark:bg-gray-700 rounded-full">Inactive</span>')
6290 if is_current_user:
6291 badges.append('<span class="px-2 py-1 text-xs font-semibold bg-blue-100 text-blue-800 rounded-full ' + 'dark:bg-blue-900 dark:text-blue-200">You</span>')
6292 if is_last_admin:
6293 badges.append('<span class="px-2 py-1 text-xs font-semibold bg-yellow-100 text-yellow-800 rounded-full ' + 'dark:bg-yellow-900 dark:text-yellow-200">Last Admin</span>')
6294 if user_obj.password_change_required:
6295 badges.append(
6296 '<span class="px-2 py-1 text-xs font-semibold bg-orange-100 text-orange-800 rounded-full '
6297 'dark:bg-orange-900 dark:text-orange-200"><i class="fas fa-key mr-1"></i>Password Change Required</span>'
6298 )
6299 if is_locked:
6300 badges.append('<span class="px-2 py-1 text-xs font-semibold bg-red-100 text-red-800 rounded-full ' + 'dark:bg-red-900 dark:text-red-200"><i class="fas fa-lock mr-1"></i>Locked</span>')
6302 actions = [
6303 f'<button class="px-3 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 '
6304 f"dark:hover:text-blue-300 border border-blue-300 dark:border-blue-600 hover:border-blue-500 "
6305 f"dark:hover:border-blue-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
6306 f'focus:ring-blue-500" hx-get="{root_path}/admin/users/{encoded_email}/edit" '
6307 f'hx-target="#user-edit-modal-content">Edit</button>'
6308 ]
6310 if not is_current_user and not is_last_admin:
6311 if is_locked:
6312 actions.append(
6313 f'<button class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 '
6314 f"dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 "
6315 f"dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
6316 f'focus:ring-indigo-500" hx-post="{root_path}/admin/users/{encoded_email}/unlock" '
6317 f'hx-confirm="Unlock this user account?" hx-target="closest .user-card" hx-swap="outerHTML">Unlock</button>'
6318 )
6320 if user_obj.is_active:
6321 actions.append(
6322 f'<button class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 hover:text-orange-800 '
6323 f"dark:hover:text-orange-300 border border-orange-300 dark:border-orange-600 hover:border-orange-500 "
6324 f"dark:hover:border-orange-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
6325 f'focus:ring-orange-500" hx-post="{root_path}/admin/users/{encoded_email}/deactivate" '
6326 f'hx-confirm="Deactivate this user?" hx-target="closest .user-card" hx-swap="outerHTML">Deactivate</button>'
6327 )
6328 else:
6329 actions.append(
6330 f'<button class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 '
6331 f"dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 "
6332 f"dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
6333 f'focus:ring-green-500" hx-post="{root_path}/admin/users/{encoded_email}/activate" '
6334 f'hx-confirm="Activate this user?" hx-target="closest .user-card" hx-swap="outerHTML">Activate</button>'
6335 )
6337 if user_obj.password_change_required:
6338 actions.append(
6339 '<span class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 bg-orange-50 '
6340 'dark:bg-orange-900/20 border border-orange-300 dark:border-orange-600 rounded-md">Password Change Required</span>'
6341 )
6342 else:
6343 actions.append(
6344 f'<button class="px-3 py-1 text-sm font-medium text-yellow-600 dark:text-yellow-400 hover:text-yellow-800 '
6345 f"dark:hover:text-yellow-300 border border-yellow-300 dark:border-yellow-600 hover:border-yellow-500 "
6346 f"dark:hover:border-yellow-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
6347 f'focus:ring-yellow-500" hx-post="{root_path}/admin/users/{encoded_email}/force-password-change" '
6348 f'hx-confirm="Force this user to change their password on next login?" hx-target="closest .user-card" '
6349 f'hx-swap="outerHTML">Force Password Change</button>'
6350 )
6352 actions.append(
6353 f'<button class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 '
6354 f"dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 "
6355 f"dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
6356 f'focus:ring-red-500" hx-delete="{root_path}/admin/users/{encoded_email}" '
6357 f'hx-confirm="Are you sure you want to delete this user? This action cannot be undone." '
6358 f'hx-target="closest .user-card" hx-swap="outerHTML">Delete</button>'
6359 )
6361 return f"""
6362 <div class="user-card border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800">
6363 <div class="flex justify-between items-start">
6364 <div class="flex-1">
6365 <div class="flex items-center gap-2 mb-2">
6366 <h3 class="text-lg font-semibold text-gray-900 dark:text-white">{display_name}</h3>
6367 {" ".join(badges)}
6368 </div>
6369 <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">📧 {safe_email}</p>
6370 <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">🔐 Provider: {auth_provider}</p>
6371 <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">⚠️ Failed attempts: {failed_attempts}</p>
6372 <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">🔒 Locked until: {lock_until_text}</p>
6373 <p class="text-sm text-gray-600 dark:text-gray-400">📅 Created: {created_at}</p>
6374 </div>
6375 <div class="flex gap-2 ml-4">
6376 {" ".join(actions)}
6377 </div>
6378 </div>
6379 </div>
6380 """
6383@admin_router.get("/users")
6384@require_permission("admin.user_management", allow_admin_bypass=False)
6385async def admin_list_users(
6386 request: Request,
6387 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
6388 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
6389 db: Session = Depends(get_db),
6390 user=Depends(get_current_user_with_permissions),
6391) -> Response:
6392 """
6393 List users for the admin UI with pagination support.
6395 This endpoint retrieves a paginated list of users from the database.
6396 Uses offset-based (page/per_page) pagination.
6397 Supports JSON response for dropdown population when format=json query parameter is provided.
6399 Args:
6400 request: FastAPI request object
6401 page: Page number (1-indexed). Default: 1.
6402 per_page: Items per page. Default: 50.
6403 db: Database session dependency
6404 user: Authenticated user dependency
6406 Returns:
6407 Dict with 'data', 'pagination', and 'links' keys containing paginated users,
6408 or JSON response for dropdown population.
6409 """
6410 if not settings.email_auth_enabled:
6411 return HTMLResponse(
6412 content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. User management requires email auth.</p></div>',
6413 status_code=200,
6414 )
6416 LOGGER.debug(f"User {get_user_email(user)} requested user list (page={page}, per_page={per_page})")
6418 auth_service = EmailAuthService(db)
6420 # Check if JSON response is requested (for dropdown population)
6421 accept_header = request.headers.get("accept", "")
6422 is_json_request = "application/json" in accept_header or request.query_params.get("format") == "json"
6424 if is_json_request:
6425 # Return JSON for dropdown population - always return first page with 100 users
6426 paginated_result = await auth_service.list_users(page=1, per_page=100)
6427 users_data = [{"email": user_obj.email, "full_name": user_obj.full_name, "is_active": user_obj.is_active, "is_admin": user_obj.is_admin} for user_obj in paginated_result.data]
6428 return ORJSONResponse(content={"users": users_data})
6430 # List users with page-based pagination
6431 paginated_result = await auth_service.list_users(page=page, per_page=per_page)
6433 # End the read-only transaction early to avoid idle-in-transaction under load
6434 db.commit()
6436 # Return standardized paginated response (for legacy compatibility)
6437 return ORJSONResponse(
6438 content={
6439 "data": [{"email": u.email, "full_name": u.full_name, "is_active": u.is_active, "is_admin": u.is_admin} for u in paginated_result.data],
6440 "pagination": paginated_result.pagination.model_dump() if paginated_result.pagination else None,
6441 "links": paginated_result.links.model_dump() if paginated_result.links else None,
6442 }
6443 )
6446@admin_router.get("/users/partial", response_class=HTMLResponse)
6447@require_permission("admin.user_management", allow_admin_bypass=False)
6448async def admin_users_partial_html(
6449 request: Request,
6450 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
6451 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
6452 render: Optional[str] = Query(None, description="Render mode: 'selector' for user selector items, 'controls' for pagination controls"),
6453 team_id: Optional[str] = Depends(_validated_team_id_param),
6454 db: Session = Depends(get_db),
6455 user=Depends(get_current_user_with_permissions),
6456) -> Response:
6457 """
6458 Return paginated users as HTML partial for HTMX requests.
6460 This endpoint returns rendered HTML for the users list with pagination controls,
6461 designed for HTMX-based dynamic updates.
6463 Args:
6464 request: FastAPI request object
6465 page: Page number (1-indexed). Default: 1.
6466 per_page: Items per page. Default: 50.
6467 render: Render mode - 'selector' returns user selector items, 'controls' returns pagination controls.
6468 team_id: Optional team ID to pre-select members in selector mode
6469 db: Database session
6470 user: Current authenticated user context
6472 Returns:
6473 Response: HTML response with users list and pagination controls
6474 """
6475 try:
6476 if not settings.email_auth_enabled:
6477 return HTMLResponse(
6478 content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. User management requires email auth.</p></div>',
6479 status_code=200,
6480 )
6482 auth_service = EmailAuthService(db)
6484 # List users with page-based pagination
6485 paginated_result = await auth_service.list_users(page=page, per_page=per_page)
6486 users_db = paginated_result.data
6487 pagination = typing_cast(PaginationMeta, paginated_result.pagination)
6489 # Get current user email
6490 current_user_email = get_user_email(user)
6492 # Check how many active admins we have
6493 admin_count = await auth_service.count_active_admin_users()
6495 # Prepare user data for template with additional flags
6496 users_data = []
6497 for user_obj in users_db:
6498 is_current_user = user_obj.email == current_user_email
6499 is_last_admin = user_obj.is_admin and user_obj.is_active and admin_count == 1
6501 users_data.append(
6502 {
6503 "email": user_obj.email,
6504 "full_name": user_obj.full_name,
6505 "is_active": user_obj.is_active,
6506 "is_admin": user_obj.is_admin,
6507 "auth_provider": user_obj.auth_provider,
6508 "created_at": user_obj.created_at,
6509 "password_change_required": user_obj.password_change_required,
6510 "failed_login_attempts": int(getattr(user_obj, "failed_login_attempts", 0) or 0),
6511 "locked_until": getattr(user_obj, "locked_until", None),
6512 "is_locked": bool(getattr(user_obj, "locked_until", None) and getattr(user_obj, "locked_until", None) > utc_now()),
6513 "is_current_user": is_current_user,
6514 "is_last_admin": is_last_admin,
6515 }
6516 )
6518 # Get team members if team_id is provided (for pre-selection in team member addition)
6519 team_member_emails = set()
6520 team_member_data = {}
6521 current_user_is_team_owner = False
6523 if team_id and render == "selector":
6524 team_service = TeamManagementService(db)
6525 try:
6526 team_members = await team_service.get_team_members(team_id)
6527 team_member_emails = {team_user.email for team_user, membership in team_members}
6529 # Build enhanced member data from the same query result (no extra DB calls!)
6530 # Count owners in-memory
6531 owner_count = sum(1 for _, membership in team_members if membership.role == "owner")
6533 # Build member data dict and find current user's role
6534 for team_user, membership in team_members:
6535 email = team_user.email
6536 is_last_owner = membership.role == "owner" and owner_count == 1
6537 team_member_data[email] = type("MemberData", (), {"role": membership.role, "joined_at": membership.joined_at, "is_last_owner": is_last_owner})()
6539 # Check if current user is owner (in-memory check)
6540 if email == current_user_email and membership.role == "owner":
6541 current_user_is_team_owner = True
6543 except Exception as e:
6544 LOGGER.warning(f"Could not fetch team members for team {team_id}: {e}")
6546 # End the read-only transaction early to avoid idle-in-transaction under load
6547 db.commit()
6549 if render == "selector":
6550 return request.app.state.templates.TemplateResponse(
6551 request,
6552 "team_members_selector.html",
6553 {
6554 "request": request,
6555 "data": users_data,
6556 "pagination": pagination.model_dump(),
6557 "root_path": request.scope.get("root_path", ""),
6558 "team_member_emails": team_member_emails,
6559 "team_member_data": team_member_data,
6560 "current_user_email": current_user_email,
6561 "current_user_is_team_owner": current_user_is_team_owner,
6562 "team_id": team_id,
6563 },
6564 )
6566 if render == "controls":
6567 base_url = f"{request.scope.get('root_path', '')}/admin/users/partial"
6568 return request.app.state.templates.TemplateResponse(
6569 request,
6570 "pagination_controls.html",
6571 {
6572 "request": request,
6573 "pagination": pagination.model_dump(),
6574 "base_url": base_url,
6575 "hx_target": "#users-list-container",
6576 "hx_indicator": "#users-loading",
6577 "hx_swap": "outerHTML",
6578 "query_params": {},
6579 "root_path": request.scope.get("root_path", ""),
6580 },
6581 )
6583 # Render template with paginated data
6584 return request.app.state.templates.TemplateResponse(
6585 request,
6586 "users_partial.html",
6587 {
6588 "request": request,
6589 "data": users_data,
6590 "pagination": pagination.model_dump(),
6591 "root_path": request.scope.get("root_path", ""),
6592 "current_user_email": current_user_email,
6593 },
6594 )
6596 except Exception as e:
6597 LOGGER.error(f"Error loading users partial for admin {user}: {e}")
6598 return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading users: {html.escape(str(e))}</p></div>', status_code=200)
6601@admin_router.get("/teams/{team_id}/members/partial", response_class=HTMLResponse)
6602@require_permission("teams.manage_members", allow_admin_bypass=False)
6603async def admin_team_members_partial_html(
6604 team_id: str,
6605 request: Request,
6606 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
6607 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
6608 db: Session = Depends(get_db),
6609 user=Depends(get_current_user_with_permissions),
6610) -> Response:
6611 """Return paginated team members for two-section layout (top section).
6613 Args:
6614 team_id: Team identifier.
6615 request: FastAPI request object.
6616 page: Page number (1-indexed). Default: 1.
6617 per_page: Items per page. Default: 50.
6618 db: Database session.
6619 user: Current authenticated user context.
6621 Returns:
6622 Response: HTML response with team members and pagination data.
6623 """
6624 try:
6625 if not settings.email_auth_enabled:
6626 return HTMLResponse(
6627 content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled.</p></div>',
6628 status_code=200,
6629 )
6631 team_service = TeamManagementService(db)
6632 current_user_email = get_user_email(user)
6634 try:
6635 team_id = _normalize_team_id(team_id)
6636 except ValueError:
6637 return HTMLResponse(content='<div class="text-red-500">Invalid team ID</div>', status_code=400)
6639 team = await team_service.get_team_by_id(team_id)
6640 if not team:
6641 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
6643 current_user_role = await team_service.get_user_role_in_team(current_user_email, team_id)
6644 if current_user_role != "owner":
6645 return HTMLResponse(content='<div class="text-red-500">Only team owners can manage members</div>', status_code=403)
6647 # Get paginated team members
6648 paginated_result = await team_service.get_team_members(team_id, page=page, per_page=per_page)
6649 members = paginated_result["data"]
6650 pagination = paginated_result["pagination"]
6652 # Count owners for is_last_owner check - must count ALL owners, not just current page
6653 owner_count = team_service.count_team_owners(team_id)
6655 # End the read-only transaction early
6656 db.commit()
6658 root_path = request.scope.get("root_path", "")
6659 next_page_url = f"{root_path}/admin/teams/{team_id}/members/partial?page={pagination.page + 1}&per_page={pagination.per_page}"
6660 response = request.app.state.templates.TemplateResponse(
6661 request,
6662 "team_users_selector.html",
6663 {
6664 "request": request,
6665 "data": members, # List of (user, membership) tuples
6666 "pagination": pagination.model_dump(),
6667 "root_path": root_path,
6668 "current_user_email": current_user_email,
6669 "current_user_is_team_owner": True, # Already verified above
6670 "owner_count": owner_count,
6671 "team_id": team_id,
6672 "is_members_list": True,
6673 "scroll_trigger_id": "members-scroll-trigger",
6674 "next_page_url": next_page_url,
6675 },
6676 )
6677 # Prevent nginx caching for real-time member list updates
6678 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
6679 response.headers["Pragma"] = "no-cache"
6680 response.headers["Expires"] = "0"
6681 return response
6683 except Exception as e:
6684 LOGGER.error(f"Error loading team members partial for team {team_id}: {e}")
6685 return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading members: {html.escape(str(e))}</p></div>', status_code=200)
6688@admin_router.get("/teams/{team_id}/non-members/partial", response_class=HTMLResponse)
6689@require_permission("teams.manage_members", allow_admin_bypass=False)
6690async def admin_team_non_members_partial_html(
6691 team_id: str,
6692 request: Request,
6693 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
6694 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
6695 db: Session = Depends(get_db),
6696 user=Depends(get_current_user_with_permissions),
6697) -> Response:
6698 """Return paginated non-members for two-section layout (bottom section).
6700 Args:
6701 team_id: Team identifier.
6702 request: FastAPI request object.
6703 page: Page number (1-indexed). Default: 1.
6704 per_page: Items per page. Default: 50.
6705 db: Database session.
6706 user: Current authenticated user context.
6708 Returns:
6709 Response: HTML response with non-members and pagination data.
6710 """
6711 try:
6712 if not settings.email_auth_enabled:
6713 return HTMLResponse(
6714 content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled.</p></div>',
6715 status_code=200,
6716 )
6718 auth_service = EmailAuthService(db)
6719 team_service = TeamManagementService(db)
6720 current_user_email = get_user_email(user)
6722 try:
6723 team_id = _normalize_team_id(team_id)
6724 except ValueError:
6725 return HTMLResponse(content='<div class="text-red-500">Invalid team ID</div>', status_code=400)
6727 team = await team_service.get_team_by_id(team_id)
6728 if not team:
6729 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
6731 current_user_role = await team_service.get_user_role_in_team(current_user_email, team_id)
6732 if current_user_role != "owner":
6733 return HTMLResponse(content='<div class="text-red-500">Only team owners can manage members</div>', status_code=403)
6735 # Get paginated non-members
6736 paginated_result = await auth_service.list_users_not_in_team(team_id, page=page, per_page=per_page)
6737 users = paginated_result.data
6738 pagination = typing_cast(PaginationMeta, paginated_result.pagination)
6740 # End the read-only transaction early
6741 db.commit()
6743 root_path = request.scope.get("root_path", "")
6744 next_page_url = f"{root_path}/admin/teams/{team_id}/non-members/partial?page={pagination.page + 1}&per_page={pagination.per_page}"
6745 response = request.app.state.templates.TemplateResponse(
6746 request,
6747 "team_users_selector.html",
6748 {
6749 "request": request,
6750 "data": users, # List of user objects
6751 "pagination": pagination.model_dump(),
6752 "root_path": root_path,
6753 "current_user_email": current_user_email,
6754 "current_user_is_team_owner": True, # Already verified above
6755 "owner_count": 0, # Not relevant for non-members
6756 "team_id": team_id,
6757 "is_members_list": False,
6758 "scroll_trigger_id": "non-members-scroll-trigger",
6759 "next_page_url": next_page_url,
6760 },
6761 )
6762 # Prevent nginx caching for real-time non-member list updates
6763 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
6764 response.headers["Pragma"] = "no-cache"
6765 response.headers["Expires"] = "0"
6766 return response
6768 except Exception as e:
6769 LOGGER.error(f"Error loading team non-members partial for team {team_id}: {e}")
6770 return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading non-members: {html.escape(str(e))}</p></div>', status_code=200)
6773@admin_router.get("/users/search", response_class=JSONResponse)
6774@require_any_permission(["admin.user_management", "teams.manage_members"], allow_admin_bypass=False)
6775async def admin_search_users(
6776 q: str = Query("", description="Search query"),
6777 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Maximum number of results to return"),
6778 db: Session = Depends(get_db),
6779 user=Depends(get_current_user_with_permissions),
6780):
6781 """
6782 Search users by email or full name.
6784 This endpoint searches users for use in search functionality like team member selection.
6786 Args:
6787 q (str): Search query string to match against email or full name
6788 limit (int): Maximum number of results to return
6789 db (Session): Database session dependency
6790 user: Current user making the request
6792 Returns:
6793 JSONResponse: Dictionary containing list of matching users and count
6794 """
6795 search_query = _normalize_search_query(q)
6796 if not settings.email_auth_enabled:
6797 return _build_search_response(entity_key="users", entity_type="users", items=[], query=search_query, tags="", tag_groups=[])
6799 user_email = get_user_email(user)
6801 if not search_query:
6802 return _build_search_response(entity_key="users", entity_type="users", items=[], query=search_query, tags="", tag_groups=[])
6804 LOGGER.debug(f"User {user_email} searching users with query: {search_query}")
6806 auth_service = EmailAuthService(db)
6808 # Use list_users with search parameter
6809 users_result = await auth_service.list_users(search=search_query, limit=limit)
6810 users_list = users_result.data
6812 # Format results for JSON response
6813 results = [
6814 {
6815 "id": user_obj.email,
6816 "name": user_obj.full_name or user_obj.email,
6817 "email": user_obj.email,
6818 "full_name": user_obj.full_name or "",
6819 "is_active": user_obj.is_active,
6820 "is_admin": user_obj.is_admin,
6821 }
6822 for user_obj in users_list
6823 ]
6825 return _build_search_response(entity_key="users", entity_type="users", items=results, query=search_query, tags="", tag_groups=[])
6828@admin_router.post("/users")
6829@require_permission("admin.user_management", allow_admin_bypass=False)
6830async def admin_create_user(
6831 request: Request,
6832 db: Session = Depends(get_db),
6833 user=Depends(get_current_user_with_permissions),
6834) -> HTMLResponse:
6835 """Create a new user via admin UI.
6837 Args:
6838 request: FastAPI request object
6839 db: Database session
6840 user: Current authenticated user context
6842 Returns:
6843 HTMLResponse: Success message or error response
6844 """
6845 try:
6846 form = await request.form()
6848 # Validate password strength
6849 password = str(form.get("password", ""))
6850 if password:
6851 is_valid, error_msg = validate_password_strength(password)
6852 if not is_valid:
6853 return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400)
6855 # First-Party
6857 auth_service = EmailAuthService(db)
6859 # Create new user
6860 new_user = await auth_service.create_user(
6861 email=str(form.get("email", "")),
6862 password=password,
6863 full_name=str(form.get("full_name", "")),
6864 is_admin=form.get("is_admin") == "on",
6865 auth_provider="local",
6866 granted_by=get_user_email(user), # Pass current admin user for audit trail
6867 )
6869 # If the user was created with the default password, optionally force password change
6870 if (
6871 settings.password_change_enforcement_enabled and getattr(settings, "require_password_change_for_default_password", True) and password == settings.default_user_password.get_secret_value()
6872 ): # nosec B105
6873 new_user.password_change_required = True
6874 db.commit()
6876 LOGGER.info(f"Admin {user} created user: {new_user.email}")
6878 # Return HX-Trigger header to refresh the users list
6879 # This will trigger a reload of the users-list-container
6880 response = HTMLResponse(content='<div class="text-green-500">User created successfully!</div>', status_code=201)
6881 response.headers["HX-Trigger"] = "userCreated"
6882 return response
6884 except Exception as e:
6885 LOGGER.error(f"Error creating user by admin {user}: {e}")
6886 return HTMLResponse(content=f'<div class="text-red-500">Error creating user: {html.escape(str(e))}</div>', status_code=400)
6889@admin_router.get("/users/{user_email}/edit")
6890@require_permission("admin.user_management", allow_admin_bypass=False)
6891async def admin_get_user_edit(
6892 user_email: str,
6893 _request: Request,
6894 db: Session = Depends(get_db),
6895 _user=Depends(get_current_user_with_permissions),
6896) -> HTMLResponse:
6897 """Get user edit form via admin UI.
6899 Args:
6900 user_email: Email of user to edit
6901 db: Database session
6903 Returns:
6904 HTMLResponse: User edit form HTML
6905 """
6906 if not settings.email_auth_enabled:
6907 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
6909 try:
6910 # Get root path for URL construction
6911 root_path = _request.scope.get("root_path", "") if _request else ""
6913 # First-Party
6915 auth_service = EmailAuthService(db)
6917 # URL decode the email
6919 decoded_email = urllib.parse.unquote(user_email)
6921 user_obj = await auth_service.get_user_by_email(decoded_email)
6922 if not user_obj:
6923 return HTMLResponse(content='<div class="text-red-500">User not found</div>', status_code=404)
6925 # Get current user's email to check if editing self
6926 current_user_email = get_user_email(_user)
6927 is_editing_self = current_user_email.lower() == decoded_email.lower()
6929 # Build Password Requirements HTML separately to avoid backslash issues inside f-strings
6930 if settings.password_require_uppercase or settings.password_require_lowercase or settings.password_require_numbers or settings.password_require_special:
6931 pr_lines = []
6932 pr_lines.append(
6933 f""" <!-- Password Requirements -->
6934 <div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
6935 <div class="flex items-start">
6936 <svg class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
6937 <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
6938 </svg>
6939 <div class="ml-3 flex-1">
6940 <h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3>
6941 <div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1">
6942 <div class="flex items-center" id="edit-req-length">
6943 <span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span>
6944 <span>At least {settings.password_min_length} characters long</span>
6945 </div>
6946 """
6947 )
6948 if settings.password_require_uppercase:
6949 pr_lines.append(
6950 """
6951 <div class="flex items-center" id="edit-req-uppercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains uppercase letters (A-Z)</span></div>
6952 """
6953 )
6954 if settings.password_require_lowercase:
6955 pr_lines.append(
6956 """
6957 <div class="flex items-center" id="edit-req-lowercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains lowercase letters (a-z)</span></div>
6958 """
6959 )
6960 if settings.password_require_numbers:
6961 pr_lines.append(
6962 """
6963 <div class="flex items-center" id="edit-req-numbers"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains numbers (0-9)</span></div>
6964 """
6965 )
6966 if settings.password_require_special:
6967 pr_lines.append(
6968 """
6969 <div class="flex items-center" id="edit-req-special"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains special characters (!@#$%^&*(),.?":{{}}|<>)</span></div>
6970 """
6971 )
6972 pr_lines.append(
6973 """
6974 </div>
6975 </div>
6976 </div>
6977 </div>
6978 """
6979 )
6980 password_requirements_html = "".join(pr_lines)
6981 else:
6982 # Intentionally an empty string for HTML insertion when no requirements apply.
6983 # This is not a password value; suppress Bandit false positive B105.
6984 password_requirements_html = "" # nosec B105
6986 # Create edit form HTML
6987 edit_form = f"""
6988 <div class="space-y-4">
6989 <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit User</h3>
6990 <div id="edit-user-error"></div>
6991 <form hx-post="{root_path}/admin/users/{user_email}/update" hx-target="#edit-user-error" hx-swap="innerHTML" class="space-y-4">
6992 <div>
6993 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
6994 <input type="email" name="email" value="{user_obj.email}" readonly
6995 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white">
6996 </div>
6997 <div>
6998 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
6999 <input type="text" name="full_name" value="{user_obj.full_name or ""}" required
7000 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">
7001 </div>
7002 {"" if is_editing_self else f'''<div>
7003 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
7004 <input type="checkbox" name="is_admin" {"checked" if user_obj.is_admin else ""}
7005 class="mr-2"> Administrator
7006 </label>
7007 </div>'''}
7008 <div>
7009 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password (leave empty to keep current)</label>
7010 <input type="password" name="password" id="password-field"
7011 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"
7012 oninput="validatePasswordRequirements(); validatePasswordMatch();">
7013 </div>
7014 <div>
7015 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
7016 <input type="password" name="confirm_password" id="confirm-password-field"
7017 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"
7018 oninput="validatePasswordMatch()">
7019 <div id="password-match-message" class="mt-1 text-sm text-red-600 hidden">Passwords do not match</div>
7020 </div>
7021 {password_requirements_html}
7022 <div
7023 id="edit-password-policy-data"
7024 class="hidden"
7025 data-min-length="{settings.password_min_length}"
7026 data-require-uppercase="{"true" if settings.password_require_uppercase else "false"}"
7027 data-require-lowercase="{"true" if settings.password_require_lowercase else "false"}"
7028 data-require-numbers="{"true" if settings.password_require_numbers else "false"}"
7029 data-require-special="{"true" if settings.password_require_special else "false"}"
7030 ></div>
7031 <div class="flex justify-end space-x-3">
7032 <button type="button" onclick="hideUserEditModal()"
7033 class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
7034 Cancel
7035 </button>
7036 <button type="submit"
7037 class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
7038 Update User
7039 </button>
7040 </div>
7041 </form>
7042 </div>
7043 """
7044 return HTMLResponse(content=edit_form)
7046 except Exception as e:
7047 LOGGER.error(f"Error getting user edit form for {user_email}: {e}")
7048 return HTMLResponse(content=f'<div class="text-red-500">Error loading user: {html.escape(str(e))}</div>', status_code=500)
7051@admin_router.post("/users/{user_email}/update")
7052@require_permission("admin.user_management", allow_admin_bypass=False)
7053async def admin_update_user(
7054 user_email: str,
7055 request: Request,
7056 db: Session = Depends(get_db),
7057 _user=Depends(get_current_user_with_permissions),
7058) -> HTMLResponse:
7059 """Update user via admin UI.
7061 Args:
7062 user_email: Email of user to update
7063 request: FastAPI request object
7064 db: Database session
7066 Returns:
7067 HTMLResponse: Success message or error response
7068 """
7069 if not settings.email_auth_enabled:
7070 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
7072 try:
7073 # First-Party
7075 auth_service = EmailAuthService(db)
7077 # URL decode the email
7079 decoded_email = urllib.parse.unquote(user_email)
7081 form = await request.form()
7082 full_name = form.get("full_name")
7083 is_admin = form.get("is_admin") == "on"
7084 password = form.get("password")
7085 confirm_password = form.get("confirm_password")
7087 # Validate password confirmation if password is being changed
7088 if password and password != confirm_password:
7089 return HTMLResponse(content='<div class="text-red-500">Passwords do not match</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"})
7091 # Get current user's email to prevent self-demotion
7092 current_user_email = get_user_email(_user)
7094 # Check if trying to remove admin privileges from last admin
7095 user_obj = await auth_service.get_user_by_email(decoded_email)
7097 # When editing self, preserve current admin status (checkbox is hidden in UI)
7098 if user_obj and current_user_email.lower() == decoded_email.lower():
7099 is_admin = user_obj.is_admin
7101 if user_obj and user_obj.is_admin and not is_admin:
7102 # This user is currently an admin and we're trying to remove admin privileges
7103 if await auth_service.is_last_active_admin(decoded_email):
7104 return HTMLResponse(
7105 content='<div class="text-red-500">Cannot remove administrator privileges from the last remaining admin user</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"}
7106 )
7108 # Update user
7109 fn_val = form.get("full_name")
7110 pw_val = form.get("password")
7111 full_name = fn_val if isinstance(fn_val, str) else None
7112 password = pw_val.strip() if isinstance(pw_val, str) and pw_val.strip() else None
7114 # Validate password if provided
7115 if password:
7116 is_valid, error_msg = validate_password_strength(password)
7117 if not is_valid:
7118 return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"})
7120 await auth_service.update_user(email=decoded_email, full_name=full_name, is_admin=is_admin, password=password, admin_origin_source="ui")
7122 # Return success message with auto-close and refresh
7123 success_html = """
7124 <div class="text-green-500 text-center p-4">
7125 <p>User updated successfully</p>
7126 </div>
7127 """
7128 response = HTMLResponse(content=success_html)
7129 response.headers["HX-Trigger"] = orjson.dumps({"adminUserAction": {"closeUserEditModal": True, "refreshUsersList": True, "delayMs": 1500}}).decode()
7130 return response
7132 except Exception as e:
7133 LOGGER.error(f"Error updating user {user_email}: {e}")
7134 return HTMLResponse(content=f'<div class="text-red-500">Error updating user: {html.escape(str(e))}</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"})
7137@admin_router.post("/users/{user_email}/activate")
7138@require_permission("admin.user_management", allow_admin_bypass=False)
7139async def admin_activate_user(
7140 user_email: str,
7141 _request: Request,
7142 db: Session = Depends(get_db),
7143 user=Depends(get_current_user_with_permissions),
7144) -> HTMLResponse:
7145 """Activate user via admin UI.
7147 Args:
7148 user_email: Email of user to activate
7149 db: Database session
7150 user: Current authenticated user context
7152 Returns:
7153 HTMLResponse: Success message or error response
7154 """
7155 if not settings.email_auth_enabled:
7156 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
7158 try:
7159 # Get root path for URL construction
7160 root_path = _request.scope.get("root_path", "") if _request else ""
7162 # First-Party
7164 auth_service = EmailAuthService(db)
7166 # URL decode the email
7168 decoded_email = urllib.parse.unquote(user_email)
7170 # Get current user email from JWT (used for logging purposes)
7171 current_user_email = get_user_email(user)
7173 user_obj = await auth_service.activate_user(decoded_email)
7174 admin_count = await auth_service.count_active_admin_users()
7175 return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path))
7177 except Exception as e:
7178 LOGGER.error(f"Error activating user {user_email}: {e}")
7179 return HTMLResponse(content=f'<div class="text-red-500">Error activating user: {html.escape(str(e))}</div>', status_code=400)
7182@admin_router.post("/users/{user_email}/deactivate")
7183@require_permission("admin.user_management", allow_admin_bypass=False)
7184async def admin_deactivate_user(
7185 user_email: str,
7186 _request: Request,
7187 db: Session = Depends(get_db),
7188 user=Depends(get_current_user_with_permissions),
7189) -> HTMLResponse:
7190 """Deactivate user via admin UI.
7192 Args:
7193 user_email: Email of user to deactivate
7194 db: Database session
7195 user: Current authenticated user context
7197 Returns:
7198 HTMLResponse: Success message or error response
7199 """
7200 if not settings.email_auth_enabled:
7201 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
7203 try:
7204 # Get root path for URL construction
7205 root_path = _request.scope.get("root_path", "") if _request else ""
7207 # First-Party
7209 auth_service = EmailAuthService(db)
7211 # URL decode the email
7213 decoded_email = urllib.parse.unquote(user_email)
7215 # Get current user email from JWT
7216 current_user_email = get_user_email(user)
7218 # Prevent self-deactivation
7219 if decoded_email == current_user_email:
7220 return HTMLResponse(content='<div class="text-red-500">Cannot deactivate your own account</div>', status_code=400)
7222 # Prevent deactivating the last active admin user
7223 if await auth_service.is_last_active_admin(decoded_email):
7224 return HTMLResponse(content='<div class="text-red-500">Cannot deactivate the last remaining admin user</div>', status_code=400)
7226 user_obj = await auth_service.deactivate_user(decoded_email)
7227 admin_count = await auth_service.count_active_admin_users()
7228 return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path))
7230 except Exception as e:
7231 LOGGER.error(f"Error deactivating user {user_email}: {e}")
7232 return HTMLResponse(content=f'<div class="text-red-500">Error deactivating user: {html.escape(str(e))}</div>', status_code=400)
7235@admin_router.delete("/users/{user_email}")
7236@require_permission("admin.user_management", allow_admin_bypass=False)
7237async def admin_delete_user(
7238 user_email: str,
7239 _request: Request,
7240 db: Session = Depends(get_db),
7241 user=Depends(get_current_user_with_permissions),
7242) -> HTMLResponse:
7243 """Delete user via admin UI.
7245 Args:
7246 user_email: Email address of user to delete
7247 _request: FastAPI request object (unused)
7248 db: Database session
7249 user: Current authenticated user context
7251 Returns:
7252 HTMLResponse: Success/error message
7253 """
7254 if not settings.email_auth_enabled:
7255 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
7257 try:
7258 # First-Party
7260 auth_service = EmailAuthService(db)
7262 # URL decode the email
7264 decoded_email = urllib.parse.unquote(user_email)
7266 # Get current user email from JWT
7267 current_user_email = get_user_email(user)
7269 # Prevent self-deletion
7270 if decoded_email == current_user_email:
7271 return HTMLResponse(content='<div class="text-red-500">Cannot delete your own account</div>', status_code=400)
7273 # Prevent deleting the last active admin user
7274 if await auth_service.is_last_active_admin(decoded_email):
7275 return HTMLResponse(content='<div class="text-red-500">Cannot delete the last remaining admin user</div>', status_code=400)
7277 await auth_service.delete_user(decoded_email)
7279 # Return empty content to remove the user from the list
7280 return HTMLResponse(content="", status_code=200)
7282 except Exception as e:
7283 LOGGER.error(f"Error deleting user {user_email}: {e}")
7284 return HTMLResponse(content=f'<div class="text-red-500">Error deleting user: {html.escape(str(e))}</div>', status_code=400)
7287@admin_router.post("/users/{user_email}/unlock")
7288@require_permission("admin.user_management", allow_admin_bypass=False)
7289async def admin_unlock_user(
7290 user_email: str,
7291 _request: Request,
7292 db: Session = Depends(get_db),
7293 user=Depends(get_current_user_with_permissions),
7294) -> HTMLResponse:
7295 """Unlock a user account from the admin UI.
7297 Args:
7298 user_email: URL-encoded email for the user to unlock.
7299 _request: Incoming HTTP request.
7300 db: Database session dependency.
7301 user: Current authenticated user context.
7303 Returns:
7304 HTMLResponse: Updated user card HTML or error snippet.
7305 """
7306 if not settings.email_auth_enabled:
7307 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
7309 try:
7310 root_path = _request.scope.get("root_path", "") if _request else ""
7311 auth_service = EmailAuthService(db)
7312 decoded_email = urllib.parse.unquote(user_email)
7313 current_user_email = get_user_email(user)
7315 user_obj = await auth_service.unlock_user_account(decoded_email, unlocked_by=current_user_email)
7316 admin_count = await auth_service.count_active_admin_users()
7317 return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path))
7318 except ValueError as exc:
7319 return HTMLResponse(content=f'<div class="text-red-500">{html.escape(str(exc))}</div>', status_code=404)
7320 except Exception as exc:
7321 LOGGER.error("Error unlocking user %s: %s", user_email, exc)
7322 return HTMLResponse(content=f'<div class="text-red-500">Error unlocking user: {html.escape(str(exc))}</div>', status_code=400)
7325@admin_router.post("/users/{user_email}/force-password-change")
7326@require_permission("admin.user_management", allow_admin_bypass=False)
7327async def admin_force_password_change(
7328 user_email: str,
7329 _request: Request,
7330 db: Session = Depends(get_db),
7331 user=Depends(get_current_user_with_permissions),
7332) -> HTMLResponse:
7333 """Force user to change password on next login.
7335 Args:
7336 user_email: Email of user to force password change
7337 _request: FastAPI request object
7338 db: Database session
7339 user: Current authenticated user context
7341 Returns:
7342 HTMLResponse: Updated user card with success message
7344 Examples:
7345 >>> from unittest.mock import MagicMock, AsyncMock
7346 >>> from fastapi import Request
7347 >>> from fastapi.responses import HTMLResponse
7348 >>>
7349 >>> # Mock request
7350 >>> mock_request = MagicMock(spec=Request)
7351 >>> mock_request.scope = {"root_path": "/test"}
7352 >>>
7353 >>> # Mock database
7354 >>> mock_db = MagicMock()
7355 >>>
7356 >>> # Mock user context
7357 >>> mock_user = MagicMock()
7358 >>> mock_user.email = "admin@example.com"
7359 >>>
7360 >>> import asyncio
7361 >>> async def test_force_password_change():
7362 ... # Note: Full test requires email_auth_enabled and valid user
7363 ... return True # Simplified test due to dependencies
7364 >>>
7365 >>> asyncio.run(test_force_password_change())
7366 True
7367 """
7368 if not settings.email_auth_enabled:
7369 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)
7371 try:
7372 # Get root path for URL construction
7373 root_path = _request.scope.get("root_path", "") if _request else ""
7375 auth_service = EmailAuthService(db)
7377 # URL decode the email
7378 decoded_email = urllib.parse.unquote(user_email)
7380 # Get current user email from JWT
7381 current_user_email = get_user_email(user)
7383 # Get the user to update
7384 user_obj = await auth_service.get_user_by_email(decoded_email)
7385 if not user_obj:
7386 return HTMLResponse(content='<div class="text-red-500">User not found</div>', status_code=404)
7388 # Set password_change_required flag
7389 user_obj.password_change_required = True
7390 db.commit()
7392 LOGGER.info(f"Admin {current_user_email} forced password change for user {decoded_email}")
7394 admin_count = await auth_service.count_active_admin_users()
7395 return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path))
7397 except Exception as e:
7398 LOGGER.error(f"Error forcing password change for user {user_email}: {e}")
7399 return HTMLResponse(content=f'<div class="text-red-500">Error forcing password change: {html.escape(str(e))}</div>', status_code=400)
7402@admin_router.get("/tools", response_model=PaginatedResponse)
7403@require_permission("tools.read", allow_admin_bypass=False)
7404async def admin_list_tools(
7405 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
7406 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
7407 include_inactive: bool = False,
7408 db: Session = Depends(get_db),
7409 user=Depends(get_current_user_with_permissions),
7410) -> Dict[str, Any]:
7411 """
7412 List tools for the admin UI with pagination support.
7414 This endpoint retrieves a paginated list of tools from the database, optionally
7415 including those that are inactive. Uses offset-based (page/per_page) pagination.
7417 Args:
7418 page (int): Page number (1-indexed). Default: 1.
7419 per_page (int): Items per page. Default: 50.
7420 include_inactive (bool): Whether to include inactive tools in the results.
7421 db (Session): Database session dependency.
7422 user (str): Authenticated user dependency.
7424 Returns:
7425 Dict with 'data', 'pagination', and 'links' keys containing paginated tools.
7427 """
7428 LOGGER.debug(f"User {get_user_email(user)} requested tool list (page={page}, per_page={per_page})")
7429 user_email = get_user_email(user)
7430 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
7431 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
7433 # Call tool_service.list_tools with page-based pagination
7434 paginated_result = await tool_service.list_tools(
7435 db=db,
7436 include_inactive=include_inactive,
7437 page=page,
7438 per_page=per_page,
7439 user_email=user_email,
7440 requesting_user_email=user_email,
7441 requesting_user_is_admin=_is_admin,
7442 requesting_user_team_roles=_team_roles,
7443 )
7445 # End the read-only transaction early to avoid idle-in-transaction under load.
7446 db.commit()
7448 # Return standardized paginated response
7449 return {
7450 "data": [tool.model_dump(by_alias=True) for tool in paginated_result["data"]],
7451 "pagination": paginated_result["pagination"].model_dump(),
7452 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
7453 }
7456@admin_router.get("/tools/partial", response_class=HTMLResponse)
7457@require_permission("tools.read", allow_admin_bypass=False)
7458async def admin_tools_partial_html(
7459 request: Request,
7460 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
7461 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
7462 q: str = Query("", description="Search query"),
7463 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
7464 include_inactive: bool = False,
7465 render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"),
7466 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
7467 team_id: Optional[str] = Depends(_validated_team_id_param),
7468 db: Session = Depends(get_db),
7469 user=Depends(get_current_user_with_permissions),
7470):
7471 """
7472 Return HTML partial for paginated tools list (HTMX endpoint).
7474 This endpoint returns only the table body rows and pagination controls
7475 for HTMX-based pagination in the admin UI.
7477 Args:
7478 request (Request): FastAPI request object.
7479 page (int): Page number (1-indexed). Default: 1.
7480 per_page (int): Items per page. Default: 50.
7481 q (str): Free-text query string.
7482 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
7483 include_inactive (bool): Whether to include inactive tools in the results.
7484 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
7485 team_id (Optional[str]): Filter by team ID.
7486 render (str): Render mode - 'controls' returns only pagination controls.
7487 db (Session): Database session dependency.
7488 user (str): Authenticated user dependency.
7490 Returns:
7491 HTMLResponse with tools table rows and pagination controls.
7492 """
7493 user_email = get_user_email(user)
7494 search_query = _normalize_search_query(q)
7495 normalized_tags = _normalize_tags_query(tags)
7496 tag_groups = _parse_tag_filter_groups(normalized_tags)
7497 LOGGER.debug(f"🔧 TOOLS PARTIAL REQUEST - User: {user_email}, team_id: {team_id}, page: {page}, render: {render}, referer: {request.headers.get('referer', 'none')}")
7499 # Build base query using tool_service's team filtering logic
7500 team_ids = await _get_user_team_ids(user, db)
7502 # Build query with eager loading for email_team to avoid N+1 queries
7503 query = select(DbTool).options(joinedload(DbTool.email_team))
7505 # Apply gateway filter if provided. Support special sentinel 'null' to
7506 # request tools with NULL gateway_id (e.g., RestTool/no gateway).
7507 if gateway_id:
7508 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
7509 if gateway_ids:
7510 # Treat literal 'null' (case-insensitive) as a request for NULL gateway_id
7511 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
7512 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
7513 if non_null_ids and null_requested:
7514 query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None)))
7515 LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL")
7516 elif null_requested:
7517 query = query.where(DbTool.gateway_id.is_(None))
7518 LOGGER.debug("Filtering tools by NULL gateway_id (RestTool)")
7519 else:
7520 query = query.where(DbTool.gateway_id.in_(non_null_ids))
7521 LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}")
7523 # Apply active/inactive filter
7524 if not include_inactive:
7525 query = query.where(DbTool.enabled.is_(True))
7527 # Build access conditions
7528 # When team_id is specified, show ONLY items from that team (simpler, team-scoped view)
7529 # When team_id is NOT specified, show all accessible items (owned + team + public)
7530 if team_id:
7531 # Team-specific view: only show tools from the specified team if user is a member
7532 if team_id in team_ids:
7533 # Apply visibility check: team/public resources + user's own resources (including private)
7534 team_access = [
7535 and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])),
7536 and_(DbTool.team_id == team_id, DbTool.owner_email == user_email),
7537 ]
7538 query = query.where(or_(*team_access))
7539 LOGGER.debug(f"Filtering tools by team_id: {team_id}")
7540 else:
7541 # User is not a member of this team, return no results
7542 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
7543 query = query.where(false())
7544 else:
7545 # All Teams view: apply standard access conditions
7546 access_conditions = []
7548 # 1. User's personal tools (owner_email matches)
7549 access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user))
7551 # 2. Team tools where user is member
7552 if team_ids:
7553 access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"])))
7555 # 3. Public tools
7556 access_conditions.append(DbTool.visibility == "public")
7558 query = query.where(or_(*access_conditions))
7560 if search_query:
7561 query = query.where(
7562 or_(
7563 _like_contains(func.lower(DbTool.id), search_query),
7564 _like_contains(func.lower(DbTool.original_name), search_query),
7565 _like_contains(func.lower(coalesce(DbTool.display_name, "")), search_query),
7566 _like_contains(func.lower(coalesce(DbTool.custom_name, "")), search_query),
7567 _like_contains(func.lower(coalesce(DbTool.description, "")), search_query),
7568 _like_contains(func.lower(coalesce(DbTool.url, "")), search_query),
7569 )
7570 )
7572 query = _apply_tag_filter_groups(query, db, DbTool.tags, tag_groups)
7574 # Apply sorting: alphabetical by URL, then name, then ID (for UI display)
7575 # Different from JSON endpoint which uses created_at DESC
7576 query = query.order_by(DbTool.url, DbTool.original_name, DbTool.id)
7578 # Use unified pagination function (offset-based for UI compatibility)
7579 root_path = request.scope.get("root_path", "")
7580 base_url = f"{root_path}/admin/tools/partial"
7581 query_params_dict = {}
7582 if include_inactive:
7583 query_params_dict["include_inactive"] = "true"
7584 if gateway_id:
7585 query_params_dict["gateway_id"] = gateway_id
7586 if team_id:
7587 query_params_dict["team_id"] = team_id
7588 if search_query:
7589 query_params_dict["q"] = search_query
7590 if normalized_tags:
7591 query_params_dict["tags"] = normalized_tags
7593 paginated_result = await paginate_query(
7594 db=db,
7595 query=query,
7596 page=page,
7597 per_page=per_page,
7598 cursor=None, # UI uses offset pagination only
7599 base_url=base_url,
7600 query_params=query_params_dict,
7601 use_cursor_threshold=False, # Disable auto-cursor switching for UI
7602 )
7604 # Extract paginated tools (DbTool objects)
7605 tools_db = paginated_result["data"]
7606 pagination = paginated_result["pagination"]
7607 links = paginated_result["links"]
7609 # Team names are loaded via joinedload(DbTool.email_team) in the query
7610 # Batch convert to Pydantic models using tool service
7611 # This eliminates the N+1 query problem from calling get_tool() in a loop
7612 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
7613 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
7614 tools_pydantic = []
7615 failed_count = 0
7616 for t in tools_db:
7617 try:
7618 tools_pydantic.append(
7619 tool_service.convert_tool_to_read(
7620 t,
7621 include_metrics=False,
7622 include_auth=False,
7623 requesting_user_email=user_email,
7624 requesting_user_is_admin=_is_admin,
7625 requesting_user_team_roles=_team_roles,
7626 )
7627 )
7628 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
7629 failed_count += 1
7630 LOGGER.exception(f"Failed to convert tool {getattr(t, 'id', 'unknown')} ({getattr(t, 'name', 'unknown')}): {e}")
7631 _adjust_pagination_for_conversion_failures(pagination, failed_count)
7633 # Serialize tools
7634 data = jsonable_encoder(tools_pydantic)
7636 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
7637 db.commit()
7639 # If render=controls, return only pagination controls
7640 if render == "controls":
7641 return request.app.state.templates.TemplateResponse(
7642 request,
7643 "pagination_controls.html",
7644 {
7645 "request": request,
7646 "pagination": pagination.model_dump(),
7647 "base_url": base_url,
7648 "hx_target": "#tools-table-body",
7649 "hx_indicator": "#tools-loading",
7650 "query_params": query_params_dict,
7651 "root_path": request.scope.get("root_path", ""),
7652 },
7653 )
7655 # If render=selector, return tool selector items for infinite scroll
7656 if render == "selector":
7657 return request.app.state.templates.TemplateResponse(
7658 request,
7659 "tools_selector_items.html",
7660 {
7661 "request": request,
7662 "data": data,
7663 "pagination": pagination.model_dump(),
7664 "root_path": request.scope.get("root_path", ""),
7665 "gateway_id": gateway_id,
7666 },
7667 )
7669 # Render template with paginated data
7670 return request.app.state.templates.TemplateResponse(
7671 request,
7672 "tools_partial.html",
7673 {
7674 "request": request,
7675 "data": data,
7676 "pagination": pagination.model_dump(),
7677 "links": links.model_dump() if links else None,
7678 "root_path": request.scope.get("root_path", ""),
7679 "include_inactive": include_inactive,
7680 "query_params": query_params_dict,
7681 "current_user_email": user_email,
7682 "is_admin": _is_admin,
7683 "user_team_roles": _team_roles,
7684 },
7685 )
7688@admin_router.get("/tool-ops/partial", response_class=HTMLResponse)
7689@require_permission("tools.read", allow_admin_bypass=False)
7690async def admin_tool_ops_partial(
7691 request: Request,
7692 page: int = Query(1, ge=1, description="Page number"),
7693 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
7694 include_inactive: bool = False,
7695 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
7696 team_id: Optional[str] = Depends(_validated_team_id_param),
7697 db: Session = Depends(get_db),
7698 user=Depends(get_current_user_with_permissions),
7699):
7700 """
7701 Return HTML partial for tool operations table.
7703 Args:
7704 request (Request): The request object.
7705 page (int): The page number. Defaults to 1.
7706 per_page (int): The number of items per page. Defaults to settings.pagination_default_page_size.
7707 include_inactive (bool): Whether to include inactive items. Defaults to False.
7708 gateway_id (Optional[str]): The gateway ID to filter by. Defaults to None.
7709 team_id (Optional[str]): The team ID to filter by. Defaults to None.
7710 db (Session): The database session. Defaults to Depends(get_db).
7711 user (Any): The current user. Defaults to Depends(get_current_user_with_permissions).
7713 Returns:
7714 HTMLResponse: The HTML partial for the tool operations table.
7715 """
7716 user_email = get_user_email(user)
7717 LOGGER.debug(f"Tool ops partial request - team_id: {team_id}, page: {page}")
7718 team_ids = await _get_user_team_ids(user, db)
7720 query = select(DbTool).options(joinedload(DbTool.email_team))
7722 if gateway_id:
7723 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
7724 if gateway_ids:
7725 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
7726 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
7727 if non_null_ids and null_requested:
7728 query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None)))
7729 LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL")
7730 elif null_requested:
7731 query = query.where(DbTool.gateway_id.is_(None))
7732 LOGGER.debug("Filtering tools by NULL gateway_id (RestTool)")
7733 else:
7734 query = query.where(DbTool.gateway_id.in_(non_null_ids))
7735 LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}")
7737 if not include_inactive:
7738 query = query.where(DbTool.enabled.is_(True))
7740 if team_id:
7741 if team_id in team_ids:
7742 team_access = [
7743 and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])),
7744 and_(DbTool.team_id == team_id, DbTool.owner_email == user_email),
7745 ]
7746 query = query.where(or_(*team_access))
7747 LOGGER.debug(f"Filtering tools by team_id: {team_id}")
7748 else:
7749 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
7750 query = query.where(false())
7751 else:
7752 access_conditions = []
7753 access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user))
7754 if team_ids:
7755 access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"])))
7756 access_conditions.append(DbTool.visibility == "public")
7757 query = query.where(or_(*access_conditions))
7759 query = query.order_by(DbTool.url, DbTool.original_name, DbTool.id)
7761 paginated_result = await paginate_query(
7762 db=db,
7763 query=query,
7764 page=page,
7765 per_page=per_page,
7766 cursor=None,
7767 base_url=f"{request.scope.get('root_path', '')}/admin/tool-ops/partial",
7768 query_params={
7769 "include_inactive": "true" if include_inactive else "false",
7770 "gateway_id": gateway_id or "",
7771 "team_id": team_id or "",
7772 },
7773 use_cursor_threshold=False,
7774 )
7776 tools_db = paginated_result["data"]
7777 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
7778 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
7779 tools_pydantic = [
7780 tool_service.convert_tool_to_read(
7781 t,
7782 include_metrics=False,
7783 include_auth=False,
7784 requesting_user_email=user_email,
7785 requesting_user_is_admin=_is_admin,
7786 requesting_user_team_roles=_team_roles,
7787 )
7788 for t in tools_db
7789 ]
7790 db.commit()
7792 return request.app.state.templates.TemplateResponse(
7793 request,
7794 "toolops_partial.html",
7795 {
7796 "request": request,
7797 "tools": tools_pydantic,
7798 "root_path": request.scope.get("root_path", ""),
7799 "current_user_email": user_email,
7800 "is_admin": _is_admin,
7801 "user_team_roles": _team_roles,
7802 },
7803 )
7806@admin_router.get("/tools/ids", response_class=JSONResponse)
7807@require_permission("tools.read", allow_admin_bypass=False)
7808async def admin_get_all_tool_ids(
7809 include_inactive: bool = False,
7810 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
7811 team_id: Optional[str] = Depends(_validated_team_id_param),
7812 db: Session = Depends(get_db),
7813 user=Depends(get_current_user_with_permissions),
7814):
7815 """
7816 Return all tool IDs accessible to the current user.
7818 This is used by "Select All" to get all tool IDs without loading full data.
7820 Args:
7821 include_inactive (bool): Whether to include inactive tools in the results
7822 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local tools).
7823 team_id (Optional[str]): Filter by team ID.
7824 db (Session): Database session dependency
7825 user: Current user making the request
7827 Returns:
7828 JSONResponse: List of tool IDs accessible to the user
7829 """
7830 user_email = get_user_email(user)
7832 # Build base query
7833 team_ids = await _get_user_team_ids(user, db)
7835 query = select(DbTool.id)
7837 if not include_inactive:
7838 query = query.where(DbTool.enabled.is_(True))
7840 # Apply optional gateway/server scoping (comma-separated ids). Accepts the
7841 # literal value 'null' to indicate NULL gateway_id (local tools).
7842 if gateway_id:
7843 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
7844 if gateway_ids:
7845 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
7846 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
7847 if non_null_ids and null_requested:
7848 query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None)))
7849 LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL")
7850 elif null_requested:
7851 query = query.where(DbTool.gateway_id.is_(None))
7852 LOGGER.debug("Filtering tools by NULL gateway_id (local tools)")
7853 else:
7854 query = query.where(DbTool.gateway_id.in_(non_null_ids))
7855 LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}")
7857 # Build access conditions
7858 # When team_id is specified, show ONLY items from that team (team-scoped view)
7859 # Otherwise, show all accessible items (All Teams view)
7860 if team_id:
7861 if team_id in team_ids:
7862 # Apply visibility check: team/public resources + user's own resources (including private)
7863 team_access = [
7864 and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])),
7865 and_(DbTool.team_id == team_id, DbTool.owner_email == user_email),
7866 ]
7867 query = query.where(or_(*team_access))
7868 LOGGER.debug(f"Filtering tool IDs by team_id: {team_id}")
7869 else:
7870 LOGGER.warning(f"User {user_email} attempted to filter tool IDs by team {team_id} but is not a member")
7871 query = query.where(false())
7872 else:
7873 # All Teams view: apply standard access conditions (owner, team, public)
7874 access_conditions = []
7875 access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user))
7876 access_conditions.append(DbTool.visibility == "public")
7877 if team_ids:
7878 access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"])))
7879 query = query.where(or_(*access_conditions))
7881 # Get all IDs
7882 tool_ids = [row[0] for row in db.execute(query).all()]
7884 return {"tool_ids": tool_ids, "count": len(tool_ids)}
7887@admin_router.get("/tools/search", response_class=JSONResponse)
7888@require_permission("tools.read", allow_admin_bypass=False)
7889async def admin_search_tools(
7890 q: str = Query("", description="Search query"),
7891 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
7892 include_inactive: bool = False,
7893 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Maximum number of results to return"),
7894 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
7895 team_id: Optional[str] = Depends(_validated_team_id_param),
7896 db: Session = Depends(get_db),
7897 user=Depends(get_current_user_with_permissions),
7898):
7899 """
7900 Search tools by name, ID, or description.
7902 This endpoint searches tools across all accessible tools for the current user,
7903 returning both IDs and names for use in search functionality like the Add Server page.
7905 Args:
7906 q (str): Search query string to match against tool names, IDs, or descriptions.
7907 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
7908 include_inactive (bool): Whether to include inactive tools in the search results.
7909 limit (int): Maximum number of results to return.
7910 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
7911 team_id (Optional[str]): Filter by team ID.
7912 db (Session): Database session.
7913 user: Current user with permissions.
7915 Returns:
7916 JSONResponse: A JSON response containing a list of matching tools.
7917 """
7918 user_email = get_user_email(user)
7919 search_query = _normalize_search_query(q)
7920 normalized_tags = _normalize_tags_query(tags)
7921 tag_groups = _parse_tag_filter_groups(normalized_tags)
7923 if not search_query and not tag_groups:
7924 return _build_search_response(entity_key="tools", entity_type="tools", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)
7926 # Build base query
7927 team_ids = await _get_user_team_ids(user, db)
7929 query = select(DbTool.id, DbTool.original_name, DbTool.custom_name, DbTool.display_name, DbTool.description)
7931 # Apply gateway filter if provided. Support special sentinel 'null' to
7932 # request tools with NULL gateway_id (e.g., RestTool/no gateway).
7933 if gateway_id:
7934 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
7935 if gateway_ids:
7936 # Treat literal 'null' (case-insensitive) as a request for NULL gateway_id
7937 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
7938 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
7939 if non_null_ids and null_requested:
7940 query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None)))
7941 LOGGER.debug(f"Filtering tool search by gateway IDs (including NULL): {non_null_ids} + NULL")
7942 elif null_requested:
7943 query = query.where(DbTool.gateway_id.is_(None))
7944 LOGGER.debug("Filtering tool search by NULL gateway_id (RestTool)")
7945 else:
7946 query = query.where(DbTool.gateway_id.in_(non_null_ids))
7947 LOGGER.debug(f"Filtering tool search by gateway IDs: {non_null_ids}")
7949 if not include_inactive:
7950 query = query.where(DbTool.enabled.is_(True))
7952 # Build access conditions
7953 # When team_id is specified, show ONLY items from that team (team-scoped view)
7954 # Otherwise, show all accessible items (All Teams view)
7955 if team_id:
7956 if team_id in team_ids:
7957 # Apply visibility check: team/public resources + user's own resources (including private)
7958 team_access = [
7959 and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])),
7960 and_(DbTool.team_id == team_id, DbTool.owner_email == user_email),
7961 ]
7962 query = query.where(or_(*team_access))
7963 LOGGER.debug(f"Filtering tool search by team_id: {team_id}")
7964 else:
7965 LOGGER.warning(f"User {user_email} attempted to filter tool search by team {team_id} but is not a member")
7966 query = query.where(false())
7967 else:
7968 # All Teams view: apply standard access conditions (owner, team, public)
7969 access_conditions = []
7970 access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user))
7971 access_conditions.append(DbTool.visibility == "public")
7972 if team_ids:
7973 access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"])))
7974 query = query.where(or_(*access_conditions))
7976 # Add search conditions - search in display fields and description
7977 # Using the same priority as display: displayName -> customName -> original_name
7978 if search_query:
7979 search_conditions = [
7980 _like_contains(func.lower(DbTool.id), search_query),
7981 _like_contains(func.lower(DbTool.original_name), search_query),
7982 _like_contains(func.lower(coalesce(DbTool.display_name, "")), search_query),
7983 _like_contains(func.lower(coalesce(DbTool.custom_name, "")), search_query),
7984 _like_contains(func.lower(coalesce(DbTool.description, "")), search_query),
7985 _like_contains(func.lower(coalesce(DbTool.url, "")), search_query),
7986 ]
7987 query = query.where(or_(*search_conditions))
7989 query = _apply_tag_filter_groups(query, db, DbTool.tags, tag_groups)
7991 # Order by relevance - prioritize matches at start of names
7992 if search_query:
7993 query = query.order_by(
7994 case(
7995 (func.lower(DbTool.original_name).startswith(search_query), 1),
7996 (func.lower(coalesce(DbTool.custom_name, "")).startswith(search_query), 1),
7997 (func.lower(coalesce(DbTool.display_name, "")).startswith(search_query), 1),
7998 else_=2,
7999 ),
8000 func.lower(DbTool.original_name),
8001 )
8002 else:
8003 query = query.order_by(func.lower(DbTool.original_name))
8004 query = query.limit(limit)
8006 # Execute query
8007 results = db.execute(query).all()
8009 # Format results
8010 tools = []
8011 for row in results:
8012 tools.append({"id": row.id, "name": row.original_name, "display_name": row.display_name, "custom_name": row.custom_name}) # original_name for search matching
8014 return _build_search_response(entity_key="tools", entity_type="tools", items=tools, query=search_query, tags=normalized_tags, tag_groups=tag_groups)
8017@admin_router.get("/prompts/partial", response_class=HTMLResponse)
8018@require_permission("prompts.read", allow_admin_bypass=False)
8019async def admin_prompts_partial_html(
8020 request: Request,
8021 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
8022 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
8023 q: str = Query("", description="Search query"),
8024 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
8025 include_inactive: bool = False,
8026 render: Optional[str] = Query(None),
8027 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
8028 team_id: Optional[str] = Depends(_validated_team_id_param),
8029 db: Session = Depends(get_db),
8030 user=Depends(get_current_user_with_permissions),
8031):
8032 """Return paginated prompts HTML partials for the admin UI.
8034 This HTMX endpoint returns only the partial HTML used by the admin UI for
8035 prompts. It supports three render modes:
8037 - default: full table partial (rows + controls)
8038 - ``render="controls"``: return only pagination controls
8039 - ``render="selector"``: return selector items for infinite scroll
8041 Args:
8042 request (Request): FastAPI request object used by the template engine.
8043 page (int): Page number (1-indexed).
8044 per_page (int): Number of items per page (bounded by settings).
8045 q (str): Free-text query string.
8046 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
8047 include_inactive (bool): If True, include inactive prompts in results.
8048 render (Optional[str]): Render mode; one of None, "controls", "selector".
8049 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
8050 team_id (Optional[str]): Filter by team ID.
8051 db (Session): Database session (dependency-injected).
8052 user: Authenticated user object from dependency injection.
8054 Returns:
8055 Union[HTMLResponse, TemplateResponse]: A rendered template response
8056 containing either the table partial, pagination controls, or selector
8057 items depending on ``render``. The response contains JSON-serializable
8058 encoded prompt data when templates expect it.
8059 """
8060 LOGGER.debug(
8061 f"User {get_user_email(user)} requested prompts HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, gateway_id={gateway_id}, team_id={team_id})"
8062 )
8063 search_query = _normalize_search_query(q)
8064 normalized_tags = _normalize_tags_query(tags)
8065 tag_groups = _parse_tag_filter_groups(normalized_tags)
8066 # Normalize per_page within configured bounds
8067 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
8069 user_email = get_user_email(user)
8071 # Team scoping
8072 team_ids = await _get_user_team_ids(user, db)
8074 # Build base query
8075 query = select(DbPrompt)
8077 # Apply gateway filter if provided
8078 if gateway_id:
8079 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
8080 if gateway_ids:
8081 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
8082 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
8083 if non_null_ids and null_requested:
8084 query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None)))
8085 LOGGER.debug(f"Filtering prompts by gateway IDs (including NULL): {non_null_ids} + NULL")
8086 elif null_requested:
8087 query = query.where(DbPrompt.gateway_id.is_(None))
8088 LOGGER.debug("Filtering prompts by NULL gateway_id (RestTool)")
8089 else:
8090 query = query.where(DbPrompt.gateway_id.in_(non_null_ids))
8091 LOGGER.debug(f"Filtering prompts by gateway IDs: {non_null_ids}")
8093 if not include_inactive:
8094 query = query.where(DbPrompt.enabled.is_(True))
8096 # Build access conditions
8097 # When team_id is specified, show ONLY items from that team (team-scoped view)
8098 # Otherwise, show all accessible items (All Teams view)
8099 if team_id:
8100 # Team-specific view: only show prompts from the specified team
8101 if team_id in team_ids:
8102 # Apply visibility check: team/public resources + user's own resources (including private)
8103 team_access = [
8104 and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])),
8105 and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email),
8106 ]
8107 query = query.where(or_(*team_access))
8108 LOGGER.debug(f"Filtering prompts by team_id: {team_id}")
8109 else:
8110 # User is not a member of this team, return no results using SQLAlchemy's false()
8111 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
8112 query = query.where(false())
8113 else:
8114 # All Teams view: apply standard access conditions (owner, team, public)
8115 access_conditions = []
8116 access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user))
8117 if team_ids:
8118 access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"])))
8119 access_conditions.append(DbPrompt.visibility == "public")
8120 query = query.where(or_(*access_conditions))
8122 if search_query:
8123 query = query.where(
8124 or_(
8125 _like_contains(func.lower(DbPrompt.id), search_query),
8126 _like_contains(func.lower(DbPrompt.original_name), search_query),
8127 _like_contains(func.lower(coalesce(DbPrompt.display_name, "")), search_query),
8128 _like_contains(func.lower(coalesce(DbPrompt.description, "")), search_query),
8129 )
8130 )
8132 query = _apply_tag_filter_groups(query, db, DbPrompt.tags, tag_groups)
8134 # Apply pagination ordering for cursor support
8135 query = query.order_by(desc(DbPrompt.created_at), desc(DbPrompt.id))
8137 # Build query params for pagination links
8138 query_params = {}
8139 if include_inactive:
8140 query_params["include_inactive"] = "true"
8141 if gateway_id:
8142 query_params["gateway_id"] = gateway_id
8143 if team_id:
8144 query_params["team_id"] = team_id
8145 if search_query:
8146 query_params["q"] = search_query
8147 if normalized_tags:
8148 query_params["tags"] = normalized_tags
8150 # Use unified pagination function
8151 root_path = request.scope.get("root_path", "")
8152 base_url = f"{root_path}/admin/prompts/partial"
8153 paginated_result = await paginate_query(
8154 db=db,
8155 query=query,
8156 page=page,
8157 per_page=per_page,
8158 cursor=None, # HTMX partials use page-based navigation
8159 base_url=base_url,
8160 query_params=query_params,
8161 use_cursor_threshold=False, # Disable auto-cursor switching for UI
8162 )
8164 # Extract paginated prompts (DbPrompt objects)
8165 prompts_db = paginated_result["data"]
8166 pagination = paginated_result["pagination"]
8167 links = paginated_result["links"]
8169 # Batch fetch team names for the prompts to avoid N+1 queries
8170 team_ids_set = {p.team_id for p in prompts_db if p.team_id}
8171 team_map = {}
8172 if team_ids_set:
8173 teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
8174 team_map = {team.id: team.name for team in teams}
8176 # Apply team names to DB objects before conversion
8177 for p in prompts_db:
8178 p.team = team_map.get(p.team_id) if p.team_id else None
8180 # Batch convert to Pydantic models using prompt service
8181 # This eliminates the N+1 query problem from calling get_prompt_details() in a loop
8182 prompts_pydantic = []
8183 failed_count = 0
8184 for p in prompts_db:
8185 try:
8186 prompts_pydantic.append(prompt_service.convert_prompt_to_read(p, include_metrics=False))
8187 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
8188 failed_count += 1
8189 LOGGER.exception(f"Failed to convert prompt {getattr(p, 'id', 'unknown')} ({getattr(p, 'name', 'unknown')}): {e}")
8190 _adjust_pagination_for_conversion_failures(pagination, failed_count)
8192 data = jsonable_encoder(prompts_pydantic)
8194 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
8195 db.commit()
8197 if render == "controls":
8198 return request.app.state.templates.TemplateResponse(
8199 request,
8200 "pagination_controls.html",
8201 {
8202 "request": request,
8203 "pagination": pagination.model_dump(),
8204 "base_url": base_url,
8205 "hx_target": "#prompts-table-body",
8206 "hx_indicator": "#prompts-loading",
8207 "query_params": query_params,
8208 "root_path": request.scope.get("root_path", ""),
8209 },
8210 )
8212 if render == "selector":
8213 return request.app.state.templates.TemplateResponse(
8214 request,
8215 "prompts_selector_items.html",
8216 {
8217 "request": request,
8218 "data": data,
8219 "pagination": pagination.model_dump(),
8220 "root_path": request.scope.get("root_path", ""),
8221 "gateway_id": gateway_id,
8222 },
8223 )
8225 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
8226 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
8227 return request.app.state.templates.TemplateResponse(
8228 request,
8229 "prompts_partial.html",
8230 {
8231 "request": request,
8232 "data": data,
8233 "pagination": pagination.model_dump(),
8234 "links": links.model_dump() if links else None,
8235 "root_path": request.scope.get("root_path", ""),
8236 "include_inactive": include_inactive,
8237 "query_params": query_params,
8238 "current_user_email": user_email,
8239 "is_admin": _is_admin,
8240 "user_team_roles": _team_roles,
8241 },
8242 )
8245@admin_router.get("/gateways/partial", response_class=HTMLResponse)
8246@require_permission("gateways.read", allow_admin_bypass=False)
8247async def admin_gateways_partial_html(
8248 request: Request,
8249 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
8250 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
8251 q: str = Query("", description="Search query"),
8252 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
8253 include_inactive: bool = False,
8254 render: Optional[str] = Query(None),
8255 team_id: Optional[str] = Depends(_validated_team_id_param),
8256 db: Session = Depends(get_db),
8257 user=Depends(get_current_user_with_permissions),
8258):
8259 """Return paginated gateways HTML partials for the admin UI.
8261 This HTMX endpoint returns only the partial HTML used by the admin UI for
8262 gateways. It supports three render modes:
8264 - default: full table partial (rows + controls)
8265 - ``render="controls"``: return only pagination controls
8266 - ``render="selector"``: return selector items for infinite scroll
8268 Args:
8269 request (Request): FastAPI request object used by the template engine.
8270 page (int): Page number (1-indexed).
8271 per_page (int): Number of items per page (bounded by settings).
8272 q (str): Free-text query string.
8273 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
8274 include_inactive (bool): If True, include inactive gateways in results.
8275 render (Optional[str]): Render mode; one of None, "controls", "selector".
8276 team_id (Optional[str]): Filter by team ID.
8277 db (Session): Database session (dependency-injected).
8278 user: Authenticated user object from dependency injection.
8280 Returns:
8281 Union[HTMLResponse, TemplateResponse]: A rendered template response
8282 containing either the table partial, pagination controls, or selector
8283 items depending on ``render``. The response contains JSON-serializable
8284 encoded gateway data when templates expect it.
8285 """
8286 user_email = get_user_email(user)
8287 search_query = _normalize_search_query(q)
8288 normalized_tags = _normalize_tags_query(tags)
8289 tag_groups = _parse_tag_filter_groups(normalized_tags)
8290 LOGGER.debug(f"🔷 GATEWAYS PARTIAL REQUEST - User: {user_email}, team_id: {team_id}, page: {page}, render: {render}, referer: {request.headers.get('referer', 'none')}")
8291 # Normalize per_page within configured bounds
8292 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
8294 # Team scoping
8295 team_ids = await _get_user_team_ids(user, db)
8297 # Build base query
8298 query = select(DbGateway).options(joinedload(DbGateway.email_team))
8300 if not include_inactive:
8301 query = query.where(DbGateway.enabled.is_(True))
8303 # Build access conditions
8304 # When team_id is specified, show ONLY items from that team (simpler, team-scoped view)
8305 # When team_id is NOT specified, show all accessible items (owned + team + public)
8306 if team_id:
8307 # Team-specific view: only show gateways from the specified team if user is a member
8308 if team_id in team_ids:
8309 # Apply visibility check: team/public resources + user's own resources (including private)
8310 team_access = [
8311 and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])),
8312 and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email),
8313 ]
8314 query = query.where(or_(*team_access))
8315 LOGGER.debug(f"Filtering gateways by team_id: {team_id}")
8316 else:
8317 # User is not a member of this team, return no results
8318 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
8319 query = query.where(false())
8320 else:
8321 # All Teams view: apply standard access conditions
8322 access_conditions = []
8323 access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user))
8324 if team_ids:
8325 access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"])))
8326 access_conditions.append(DbGateway.visibility == "public")
8328 query = query.where(or_(*access_conditions))
8330 if search_query:
8331 query = query.where(
8332 or_(
8333 _like_contains(func.lower(DbGateway.id), search_query),
8334 _like_contains(func.lower(DbGateway.name), search_query),
8335 _like_contains(func.lower(coalesce(DbGateway.url, "")), search_query),
8336 _like_contains(func.lower(coalesce(DbGateway.description, "")), search_query),
8337 )
8338 )
8340 query = _apply_tag_filter_groups(query, db, DbGateway.tags, tag_groups)
8342 # Apply pagination ordering for cursor support
8343 query = query.order_by(desc(DbGateway.created_at), desc(DbGateway.id))
8345 # Build query params for pagination links
8346 query_params = {}
8347 if include_inactive:
8348 query_params["include_inactive"] = "true"
8349 if team_id:
8350 query_params["team_id"] = team_id
8351 if search_query:
8352 query_params["q"] = search_query
8353 if normalized_tags:
8354 query_params["tags"] = normalized_tags
8356 # Use unified pagination function
8357 root_path = request.scope.get("root_path", "")
8358 base_url = f"{root_path}/admin/gateways/partial"
8359 paginated_result = await paginate_query(
8360 db=db,
8361 query=query,
8362 page=page,
8363 per_page=per_page,
8364 cursor=None, # HTMX partials use page-based navigation
8365 base_url=base_url,
8366 query_params=query_params,
8367 use_cursor_threshold=False, # Disable auto-cursor switching for UI
8368 )
8370 # Extract paginated gateways (DbGateway objects)
8371 gateways_db = paginated_result["data"]
8372 pagination = paginated_result["pagination"]
8373 links = paginated_result["links"]
8375 # Batch convert to Pydantic models using gateway service
8376 # This eliminates the N+1 query problem from calling get_gateway_details() in a loop
8377 gateways_pydantic = []
8378 failed_count = 0
8379 for g in gateways_db:
8380 try:
8381 gateways_pydantic.append(gateway_service.convert_gateway_to_read(g))
8382 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
8383 failed_count += 1
8384 LOGGER.exception(f"Failed to convert gateway {getattr(g, 'id', 'unknown')} ({getattr(g, 'name', 'unknown')}): {e}")
8385 _adjust_pagination_for_conversion_failures(pagination, failed_count)
8386 data = jsonable_encoder(gateways_pydantic)
8388 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
8389 db.commit()
8391 LOGGER.info(f"🔷 GATEWAYS PARTIAL RESPONSE - Returning {len(data)} gateways, render mode: {render or 'default'}, team_id used in query: {team_id}")
8393 if render == "controls":
8394 return request.app.state.templates.TemplateResponse(
8395 request,
8396 "pagination_controls.html",
8397 {
8398 "request": request,
8399 "pagination": pagination.model_dump(),
8400 "base_url": base_url,
8401 "hx_target": "#gateways-table-body",
8402 "hx_indicator": "#gateways-loading",
8403 "query_params": query_params,
8404 "root_path": request.scope.get("root_path", ""),
8405 },
8406 )
8408 if render == "selector":
8409 return request.app.state.templates.TemplateResponse(
8410 request,
8411 "gateways_selector_items.html",
8412 {"request": request, "data": data, "pagination": pagination.model_dump(), "root_path": request.scope.get("root_path", "")},
8413 )
8415 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
8416 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
8417 return request.app.state.templates.TemplateResponse(
8418 request,
8419 "gateways_partial.html",
8420 {
8421 "request": request,
8422 "data": data,
8423 "pagination": pagination.model_dump(),
8424 "links": links.model_dump() if links else None,
8425 "root_path": request.scope.get("root_path", ""),
8426 "include_inactive": include_inactive,
8427 "query_params": query_params,
8428 "current_user_email": user_email,
8429 "is_admin": _is_admin,
8430 "user_team_roles": _team_roles,
8431 },
8432 )
8435@admin_router.get("/gateways/ids", response_class=JSONResponse)
8436@require_permission("gateways.read", allow_admin_bypass=False)
8437async def admin_get_all_gateways_ids(
8438 include_inactive: bool = False,
8439 team_id: Optional[str] = Depends(_validated_team_id_param),
8440 db: Session = Depends(get_db),
8441 user=Depends(get_current_user_with_permissions),
8442):
8443 """Return all gateway IDs accessible to the current user (select-all helper).
8445 This endpoint is used by UI "Select All" helpers to fetch only the IDs
8446 of gateways the requesting user can access (owner, team, or public).
8448 Args:
8449 include_inactive (bool): When True include prompts that are inactive.
8450 team_id (Optional[str]): Filter by team ID.
8451 db (Session): Database session (injected dependency).
8452 user: Authenticated user object from dependency injection.
8454 Returns:
8455 dict: A dictionary containing two keys:
8456 - "prompt_ids": List[str] of accessible prompt IDs.
8457 - "count": int number of IDs returned.
8458 """
8459 user_email = get_user_email(user)
8460 team_ids = await _get_user_team_ids(user, db)
8462 query = select(DbGateway.id)
8464 if not include_inactive:
8465 query = query.where(DbGateway.enabled.is_(True))
8467 # Build access conditions
8468 # When team_id is specified, show ONLY items from that team (team-scoped view)
8469 # Otherwise, show all accessible items (All Teams view)
8470 if team_id:
8471 if team_id in team_ids:
8472 # Apply visibility check: team/public resources + user's own resources (including private)
8473 team_access = [
8474 and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])),
8475 and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email),
8476 ]
8477 query = query.where(or_(*team_access))
8478 LOGGER.debug(f"Filtering gateway IDs by team_id: {team_id}")
8479 else:
8480 LOGGER.warning(f"User {user_email} attempted to filter gateway IDs by team {team_id} but is not a member")
8481 query = query.where(false())
8482 else:
8483 # All Teams view: apply standard access conditions (owner, team, public)
8484 access_conditions = []
8485 access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user))
8486 access_conditions.append(DbGateway.visibility == "public")
8487 if team_ids:
8488 access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"])))
8489 query = query.where(or_(*access_conditions))
8491 gateway_ids = [row[0] for row in db.execute(query).all()]
8492 return {"gateway_ids": gateway_ids, "count": len(gateway_ids)}
8495@admin_router.get("/gateways/search", response_class=JSONResponse)
8496@require_permission("gateways.read", allow_admin_bypass=False)
8497async def admin_search_gateways(
8498 q: str = Query("", description="Search query"),
8499 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
8500 include_inactive: bool = False,
8501 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
8502 team_id: Optional[str] = Depends(_validated_team_id_param),
8503 db: Session = Depends(get_db),
8504 user=Depends(get_current_user_with_permissions),
8505):
8506 """Search gateways by name or description for selector search.
8508 Performs a case-insensitive search over prompt names and descriptions
8509 and returns a limited list of matching gateways suitable for selector
8510 UIs (id, name, description).
8512 Args:
8513 q (str): Search query string.
8514 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
8515 include_inactive (bool): When True include gateways that are inactive.
8516 limit (int): Maximum number of results to return (bounded by the query parameter).
8517 team_id (Optional[str]): Filter by team ID.
8518 db (Session): Database session (injected dependency).
8519 user: Authenticated user object from dependency injection.
8521 Returns:
8522 dict: A dictionary containing:
8523 - "gateways": List[dict] where each dict has keys "id", "name", "description".
8524 - "count": int number of matched gateways returned.
8525 """
8526 user_email = get_user_email(user)
8527 search_query = _normalize_search_query(q)
8528 normalized_tags = _normalize_tags_query(tags)
8529 tag_groups = _parse_tag_filter_groups(normalized_tags)
8530 if not search_query and not tag_groups:
8531 return _build_search_response(entity_key="gateways", entity_type="gateways", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)
8533 team_ids = await _get_user_team_ids(user, db)
8535 query = select(DbGateway.id, DbGateway.name, DbGateway.url, DbGateway.description)
8537 if not include_inactive:
8538 query = query.where(DbGateway.enabled.is_(True))
8540 # Build access conditions
8541 # When team_id is specified, show ONLY items from that team (team-scoped view)
8542 # Otherwise, show all accessible items (All Teams view)
8543 if team_id:
8544 if team_id in team_ids:
8545 # Apply visibility check: team/public resources + user's own resources (including private)
8546 team_access = [
8547 and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])),
8548 and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email),
8549 ]
8550 query = query.where(or_(*team_access))
8551 LOGGER.debug(f"Filtering gateway search by team_id: {team_id}")
8552 else:
8553 LOGGER.warning(f"User {user_email} attempted to filter gateway search by team {team_id} but is not a member")
8554 query = query.where(false())
8555 else:
8556 # All Teams view: apply standard access conditions (owner, team, public)
8557 access_conditions = []
8558 access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user))
8559 access_conditions.append(DbGateway.visibility == "public")
8560 if team_ids:
8561 access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"])))
8562 query = query.where(or_(*access_conditions))
8564 if search_query:
8565 search_conditions = [
8566 _like_contains(func.lower(DbGateway.id), search_query),
8567 _like_contains(func.lower(DbGateway.name), search_query),
8568 _like_contains(func.lower(coalesce(DbGateway.url, "")), search_query),
8569 _like_contains(func.lower(coalesce(DbGateway.description, "")), search_query),
8570 ]
8571 query = query.where(or_(*search_conditions))
8573 query = _apply_tag_filter_groups(query, db, DbGateway.tags, tag_groups)
8575 if search_query:
8576 query = query.order_by(
8577 case(
8578 (func.lower(DbGateway.name).startswith(search_query), 1),
8579 (func.lower(coalesce(DbGateway.url, "")).startswith(search_query), 1),
8580 else_=2,
8581 ),
8582 func.lower(DbGateway.name),
8583 )
8584 else:
8585 query = query.order_by(func.lower(DbGateway.name))
8586 query = query.limit(limit)
8588 results = db.execute(query).all()
8589 gateways = []
8590 for row in results:
8591 gateways.append(
8592 {
8593 "id": row.id,
8594 "name": row.name,
8595 "url": row.url,
8596 "description": row.description,
8597 }
8598 )
8600 return _build_search_response(entity_key="gateways", entity_type="gateways", items=gateways, query=search_query, tags=normalized_tags, tag_groups=tag_groups)
8603@admin_router.get("/servers/ids", response_class=JSONResponse)
8604@require_permission("servers.read", allow_admin_bypass=False)
8605async def admin_get_all_server_ids(
8606 include_inactive: bool = False,
8607 team_id: Optional[str] = Depends(_validated_team_id_param),
8608 db: Session = Depends(get_db),
8609 user=Depends(get_current_user_with_permissions),
8610):
8611 """Return all server IDs accessible to the current user (select-all helper).
8613 This endpoint is used by UI "Select All" helpers to fetch only the IDs
8614 of servers the requesting user can access (owner, team, or public).
8616 Args:
8617 include_inactive (bool): When True include servers that are inactive.
8618 team_id (Optional[str]): Filter by team ID.
8619 db (Session): Database session (injected dependency).
8620 user: Authenticated user object from dependency injection.
8622 Returns:
8623 dict: A dictionary containing two keys:
8624 - "server_ids": List[str] of accessible server IDs.
8625 - "count": int number of IDs returned.
8626 """
8627 user_email = get_user_email(user)
8628 team_ids = await _get_user_team_ids(user, db)
8630 query = select(DbServer.id)
8632 if not include_inactive:
8633 query = query.where(DbServer.enabled.is_(True))
8635 # Build access conditions
8636 # When team_id is specified, show ONLY items from that team (team-scoped view)
8637 # Otherwise, show all accessible items (All Teams view)
8638 if team_id:
8639 # Team-specific view: only show servers from the specified team
8640 if team_id in team_ids:
8641 # Apply visibility check: team/public resources + user's own resources (including private)
8642 team_access = [
8643 and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])),
8644 and_(DbServer.team_id == team_id, DbServer.owner_email == user_email),
8645 ]
8646 query = query.where(or_(*team_access))
8647 LOGGER.debug(f"Filtering server IDs by team_id: {team_id}")
8648 else:
8649 # User is not a member of this team, return no results using SQLAlchemy's false()
8650 LOGGER.warning(f"User {user_email} attempted to filter server IDs by team {team_id} but is not a member")
8651 query = query.where(false())
8652 else:
8653 # All Teams view: apply standard access conditions (owner, team, public)
8654 access_conditions = []
8655 access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user))
8656 if team_ids:
8657 access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"])))
8658 access_conditions.append(DbServer.visibility == "public")
8659 query = query.where(or_(*access_conditions))
8661 server_ids = [row[0] for row in db.execute(query).all()]
8662 return {"server_ids": server_ids, "count": len(server_ids)}
8665@admin_router.get("/servers/search", response_class=JSONResponse)
8666@require_permission("servers.read", allow_admin_bypass=False)
8667async def admin_search_servers(
8668 q: str = Query("", description="Search query"),
8669 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
8670 include_inactive: bool = False,
8671 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
8672 team_id: Optional[str] = Depends(_validated_team_id_param),
8673 db: Session = Depends(get_db),
8674 user=Depends(get_current_user_with_permissions),
8675):
8676 """Search servers by name or description for selector search.
8678 Performs a case-insensitive search over prompt names and descriptions
8679 and returns a limited list of matching servers suitable for selector
8680 UIs (id, name, description).
8682 Args:
8683 q (str): Search query string.
8684 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
8685 include_inactive (bool): When True include servers that are inactive.
8686 limit (int): Maximum number of results to return (bounded by the query parameter).
8687 team_id (Optional[str]): Filter by team ID.
8688 db (Session): Database session (injected dependency).
8689 user: Authenticated user object from dependency injection.
8691 Returns:
8692 dict: A dictionary containing:
8693 - "servers": List[dict] where each dict has keys "id", "name", "description".
8694 - "count": int number of matched servers returned.
8695 """
8696 user_email = get_user_email(user)
8697 search_query = _normalize_search_query(q)
8698 normalized_tags = _normalize_tags_query(tags)
8699 tag_groups = _parse_tag_filter_groups(normalized_tags)
8700 if not search_query and not tag_groups:
8701 return _build_search_response(entity_key="servers", entity_type="servers", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)
8703 team_ids = await _get_user_team_ids(user, db)
8705 query = select(DbServer.id, DbServer.name, DbServer.description)
8707 if not include_inactive:
8708 query = query.where(DbServer.enabled.is_(True))
8710 # Build access conditions
8711 # When team_id is specified, show ONLY items from that team (team-scoped view)
8712 # Otherwise, show all accessible items (All Teams view)
8713 if team_id:
8714 # Team-specific view: only show servers from the specified team
8715 if team_id in team_ids:
8716 # Apply visibility check: team/public resources + user's own resources (including private)
8717 team_access = [
8718 and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])),
8719 and_(DbServer.team_id == team_id, DbServer.owner_email == user_email),
8720 ]
8721 query = query.where(or_(*team_access))
8722 LOGGER.debug(f"Filtering server search by team_id: {team_id}")
8723 else:
8724 # User is not a member of this team, return no results using SQLAlchemy's false()
8725 LOGGER.warning(f"User {user_email} attempted to filter server search by team {team_id} but is not a member")
8726 query = query.where(false())
8727 else:
8728 # All Teams view: apply standard access conditions (owner, team, public)
8729 access_conditions = []
8730 access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user))
8731 if team_ids:
8732 access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"])))
8733 access_conditions.append(DbServer.visibility == "public")
8734 query = query.where(or_(*access_conditions))
8736 if search_query:
8737 search_conditions = [
8738 _like_contains(func.lower(DbServer.id), search_query),
8739 _like_contains(func.lower(DbServer.name), search_query),
8740 _like_contains(func.lower(coalesce(DbServer.description, "")), search_query),
8741 ]
8742 query = query.where(or_(*search_conditions))
8744 query = _apply_tag_filter_groups(query, db, DbServer.tags, tag_groups)
8746 if search_query:
8747 query = query.order_by(
8748 case(
8749 (func.lower(DbServer.name).startswith(search_query), 1),
8750 else_=2,
8751 ),
8752 func.lower(DbServer.name),
8753 )
8754 else:
8755 query = query.order_by(func.lower(DbServer.name))
8756 query = query.limit(limit)
8758 results = db.execute(query).all()
8759 servers = []
8760 for row in results:
8761 servers.append(
8762 {
8763 "id": row.id,
8764 "name": row.name,
8765 "description": row.description,
8766 }
8767 )
8769 return _build_search_response(entity_key="servers", entity_type="servers", items=servers, query=search_query, tags=normalized_tags, tag_groups=tag_groups)
8772@admin_router.get("/resources/partial", response_class=HTMLResponse)
8773@require_permission("resources.read", allow_admin_bypass=False)
8774async def admin_resources_partial_html(
8775 request: Request,
8776 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
8777 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
8778 q: str = Query("", description="Search query"),
8779 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
8780 include_inactive: bool = False,
8781 render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"),
8782 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
8783 team_id: Optional[str] = Depends(_validated_team_id_param),
8784 db: Session = Depends(get_db),
8785 user=Depends(get_current_user_with_permissions),
8786):
8787 """Return HTML partial for paginated resources list (HTMX endpoint).
8789 This endpoint mirrors the behavior of the tools and prompts partial
8790 endpoints. It returns a template fragment suitable for HTMX-based
8791 pagination/infinite-scroll within the admin UI.
8793 Args:
8794 request (Request): FastAPI request object used by the template engine.
8795 page (int): Page number (1-indexed).
8796 per_page (int): Number of items per page (bounded by settings).
8797 q (str): Free-text query string.
8798 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
8799 include_inactive (bool): If True, include inactive resources in results.
8800 render (Optional[str]): Render mode; when set to "controls" returns only
8801 pagination controls. Other supported value: "selector" for selector
8802 items used by infinite scroll selectors.
8803 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
8804 team_id (Optional[str]): Filter by team ID.
8805 db (Session): Database session (dependency-injected).
8806 user: Authenticated user object from dependency injection.
8808 Returns:
8809 Union[HTMLResponse, TemplateResponse]: Rendered template response with the
8810 resources partial (rows + controls), pagination controls only, or selector
8811 items depending on the ``render`` parameter.
8812 """
8814 LOGGER.debug(
8815 f"[RESOURCES FILTER DEBUG] User {get_user_email(user)} requested resources HTML partial (page={page}, per_page={per_page}, render={render}, gateway_id={gateway_id}, team_id={team_id})"
8816 )
8817 search_query = _normalize_search_query(q)
8818 normalized_tags = _normalize_tags_query(tags)
8819 tag_groups = _parse_tag_filter_groups(normalized_tags)
8821 # Normalize per_page
8822 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
8824 user_email = get_user_email(user)
8826 # Team scoping
8827 team_ids = await _get_user_team_ids(user, db)
8829 # Build base query
8830 query = select(DbResource)
8832 # Apply gateway filter if provided
8833 if gateway_id:
8834 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
8835 if gateway_ids:
8836 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
8837 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
8838 if non_null_ids and null_requested:
8839 query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None)))
8840 LOGGER.debug(f"[RESOURCES FILTER DEBUG] Filtering resources by gateway IDs (including NULL): {non_null_ids} + NULL")
8841 elif null_requested:
8842 query = query.where(DbResource.gateway_id.is_(None))
8843 LOGGER.debug("[RESOURCES FILTER DEBUG] Filtering resources by NULL gateway_id (RestTool)")
8844 else:
8845 query = query.where(DbResource.gateway_id.in_(non_null_ids))
8846 LOGGER.debug(f"[RESOURCES FILTER DEBUG] Filtering resources by gateway IDs: {non_null_ids}")
8847 else:
8848 LOGGER.debug("[RESOURCES FILTER DEBUG] No gateway_id filter provided, showing all resources")
8850 # Apply active/inactive filter
8851 if not include_inactive:
8852 query = query.where(DbResource.enabled.is_(True))
8854 # Build access conditions
8855 # When team_id is specified, show ONLY items from that team (team-scoped view)
8856 # Otherwise, show all accessible items (All Teams view)
8857 if team_id:
8858 # Team-specific view: only show resources from the specified team
8859 if team_id in team_ids:
8860 # Apply visibility check: team/public resources + user's own resources (including private)
8861 team_access = [
8862 and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])),
8863 and_(DbResource.team_id == team_id, DbResource.owner_email == user_email),
8864 ]
8865 query = query.where(or_(*team_access))
8866 LOGGER.debug(f"Filtering resources by team_id: {team_id}")
8867 else:
8868 # User is not a member of this team, return no results using SQLAlchemy's false()
8869 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
8870 query = query.where(false())
8871 else:
8872 # All Teams view: apply standard access conditions (owner, team, public)
8873 access_conditions = []
8874 access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user))
8875 if team_ids:
8876 access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"])))
8877 access_conditions.append(DbResource.visibility == "public")
8878 query = query.where(or_(*access_conditions))
8880 if search_query:
8881 query = query.where(
8882 or_(
8883 _like_contains(func.lower(DbResource.id), search_query),
8884 _like_contains(func.lower(DbResource.name), search_query),
8885 _like_contains(func.lower(coalesce(DbResource.uri, "")), search_query),
8886 _like_contains(func.lower(coalesce(DbResource.description, "")), search_query),
8887 )
8888 )
8890 query = _apply_tag_filter_groups(query, db, DbResource.tags, tag_groups)
8892 # Add sorting for consistent pagination
8893 query = query.order_by(desc(DbResource.created_at), desc(DbResource.id))
8895 # Build query params for pagination links
8896 query_params = {}
8897 if include_inactive:
8898 query_params["include_inactive"] = "true"
8899 if gateway_id:
8900 query_params["gateway_id"] = gateway_id
8901 if team_id:
8902 query_params["team_id"] = team_id
8903 if search_query:
8904 query_params["q"] = search_query
8905 if normalized_tags:
8906 query_params["tags"] = normalized_tags
8908 # Use unified pagination function
8909 root_path = request.scope.get("root_path", "")
8910 base_url = f"{root_path}/admin/resources/partial"
8911 paginated_result = await paginate_query(
8912 db=db,
8913 query=query,
8914 page=page,
8915 per_page=per_page,
8916 cursor=None, # HTMX partials use page-based navigation
8917 base_url=base_url,
8918 query_params=query_params,
8919 use_cursor_threshold=False, # Disable auto-cursor switching for UI
8920 )
8922 # Extract paginated resources (DbResource objects)
8923 resources_db = paginated_result["data"]
8924 pagination = paginated_result["pagination"]
8925 links = paginated_result["links"]
8927 # Batch fetch team names for the resources to avoid N+1 queries
8928 team_ids_set = {r.team_id for r in resources_db if r.team_id}
8929 team_map = {}
8930 if team_ids_set:
8931 teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
8932 team_map = {team.id: team.name for team in teams}
8934 # Apply team names to DB objects before conversion
8935 for r in resources_db:
8936 r.team = team_map.get(r.team_id) if r.team_id else None
8938 # Batch convert to Pydantic models using resource service
8939 resources_pydantic = []
8940 failed_count = 0
8941 for r in resources_db:
8942 try:
8943 resources_pydantic.append(resource_service.convert_resource_to_read(r, include_metrics=False))
8944 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
8945 failed_count += 1
8946 LOGGER.exception(f"Failed to convert resource {getattr(r, 'id', 'unknown')} ({getattr(r, 'name', 'unknown')}): {e}")
8947 _adjust_pagination_for_conversion_failures(pagination, failed_count)
8949 data = jsonable_encoder(resources_pydantic)
8951 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
8952 db.commit()
8954 if render == "controls":
8955 return request.app.state.templates.TemplateResponse(
8956 request,
8957 "pagination_controls.html",
8958 {
8959 "request": request,
8960 "pagination": pagination.model_dump(),
8961 "base_url": base_url,
8962 "hx_target": "#resources-table-body",
8963 "hx_indicator": "#resources-loading",
8964 "query_params": query_params,
8965 "root_path": request.scope.get("root_path", ""),
8966 },
8967 )
8969 if render == "selector":
8970 return request.app.state.templates.TemplateResponse(
8971 request,
8972 "resources_selector_items.html",
8973 {
8974 "request": request,
8975 "data": data,
8976 "pagination": pagination.model_dump(),
8977 "root_path": request.scope.get("root_path", ""),
8978 "gateway_id": gateway_id,
8979 },
8980 )
8982 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
8983 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
8984 return request.app.state.templates.TemplateResponse(
8985 request,
8986 "resources_partial.html",
8987 {
8988 "request": request,
8989 "data": data,
8990 "pagination": pagination.model_dump(),
8991 "links": links.model_dump() if links else None,
8992 "root_path": request.scope.get("root_path", ""),
8993 "include_inactive": include_inactive,
8994 "query_params": query_params,
8995 "current_user_email": user_email,
8996 "is_admin": _is_admin,
8997 "user_team_roles": _team_roles,
8998 },
8999 )
9002@admin_router.get("/prompts/ids", response_class=JSONResponse)
9003@require_permission("prompts.read", allow_admin_bypass=False)
9004async def admin_get_all_prompt_ids(
9005 include_inactive: bool = False,
9006 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
9007 team_id: Optional[str] = Depends(_validated_team_id_param),
9008 db: Session = Depends(get_db),
9009 user=Depends(get_current_user_with_permissions),
9010):
9011 """Return all prompt IDs accessible to the current user (select-all helper).
9013 This endpoint is used by UI "Select All" helpers to fetch only the IDs
9014 of prompts the requesting user can access (owner, team, or public).
9016 Args:
9017 include_inactive (bool): When True include prompts that are inactive.
9018 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local prompts).
9019 team_id (Optional[str]): Filter by team ID.
9020 db (Session): Database session (injected dependency).
9021 user: Authenticated user object from dependency injection.
9023 Returns:
9024 dict: A dictionary containing two keys:
9025 - "prompt_ids": List[str] of accessible prompt IDs.
9026 - "count": int number of IDs returned.
9027 """
9028 user_email = get_user_email(user)
9029 team_ids = await _get_user_team_ids(user, db)
9031 query = select(DbPrompt.id)
9033 # Apply optional gateway/server scoping
9034 if gateway_id:
9035 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
9036 if gateway_ids:
9037 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
9038 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
9039 if non_null_ids and null_requested:
9040 query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None)))
9041 LOGGER.debug(f"Filtering prompts by gateway IDs (including NULL): {non_null_ids} + NULL")
9042 elif null_requested:
9043 query = query.where(DbPrompt.gateway_id.is_(None))
9044 LOGGER.debug("Filtering prompts by NULL gateway_id (RestTool)")
9045 else:
9046 query = query.where(DbPrompt.gateway_id.in_(non_null_ids))
9047 LOGGER.debug(f"Filtering prompts by gateway IDs: {non_null_ids}")
9049 if not include_inactive:
9050 query = query.where(DbPrompt.enabled.is_(True))
9052 # Build access conditions
9053 # When team_id is specified, show ONLY items from that team (team-scoped view)
9054 # Otherwise, show all accessible items (All Teams view)
9055 if team_id:
9056 # Team-specific view: only show prompts from the specified team
9057 if team_id in team_ids:
9058 # Apply visibility check: team/public resources + user's own resources (including private)
9059 team_access = [
9060 and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])),
9061 and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email),
9062 ]
9063 query = query.where(or_(*team_access))
9064 LOGGER.debug(f"Filtering prompt IDs by team_id: {team_id}")
9065 else:
9066 # User is not a member of this team, return no results using SQLAlchemy's false()
9067 LOGGER.warning(f"User {user_email} attempted to filter prompt IDs by team {team_id} but is not a member")
9068 query = query.where(false())
9069 else:
9070 # All Teams view: apply standard access conditions (owner, team, public)
9071 access_conditions = []
9072 access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user))
9073 if team_ids:
9074 access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"])))
9075 access_conditions.append(DbPrompt.visibility == "public")
9076 query = query.where(or_(*access_conditions))
9078 prompt_ids = [row[0] for row in db.execute(query).all()]
9079 return {"prompt_ids": prompt_ids, "count": len(prompt_ids)}
9082@admin_router.get("/resources/ids", response_class=JSONResponse)
9083@require_permission("resources.read", allow_admin_bypass=False)
9084async def admin_get_all_resource_ids(
9085 include_inactive: bool = False,
9086 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
9087 team_id: Optional[str] = Depends(_validated_team_id_param),
9088 db: Session = Depends(get_db),
9089 user=Depends(get_current_user_with_permissions),
9090):
9091 """Return all resource IDs accessible to the current user (select-all helper).
9093 This endpoint is used by UI "Select All" helpers to fetch only the IDs
9094 of resources the requesting user can access (owner, team, or public).
9096 Args:
9097 include_inactive (bool): Whether to include inactive resources in the results.
9098 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local resources).
9099 team_id (Optional[str]): Filter by team ID.
9100 db (Session): Database session dependency.
9101 user: Authenticated user object from dependency injection.
9103 Returns:
9104 dict: A dictionary containing two keys:
9105 - "resource_ids": List[str] of accessible resource IDs.
9106 - "count": int number of IDs returned.
9107 """
9108 user_email = get_user_email(user)
9109 team_ids = await _get_user_team_ids(user, db)
9111 query = select(DbResource.id)
9113 # Apply optional gateway/server scoping
9114 if gateway_id:
9115 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
9116 if gateway_ids:
9117 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
9118 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
9119 if non_null_ids and null_requested:
9120 query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None)))
9121 LOGGER.debug(f"Filtering resources by gateway IDs (including NULL): {non_null_ids} + NULL")
9122 elif null_requested:
9123 query = query.where(DbResource.gateway_id.is_(None))
9124 LOGGER.debug("Filtering resources by NULL gateway_id (RestTool)")
9125 else:
9126 query = query.where(DbResource.gateway_id.in_(non_null_ids))
9127 LOGGER.debug(f"Filtering resources by gateway IDs: {non_null_ids}")
9129 if not include_inactive:
9130 query = query.where(DbResource.enabled.is_(True))
9132 # Build access conditions
9133 # When team_id is specified, show ONLY items from that team (team-scoped view)
9134 # Otherwise, show all accessible items (All Teams view)
9135 if team_id:
9136 # Team-specific view: only show resources from the specified team
9137 if team_id in team_ids:
9138 # Apply visibility check: team/public resources + user's own resources (including private)
9139 team_access = [
9140 and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])),
9141 and_(DbResource.team_id == team_id, DbResource.owner_email == user_email),
9142 ]
9143 query = query.where(or_(*team_access))
9144 LOGGER.debug(f"Filtering resource IDs by team_id: {team_id}")
9145 else:
9146 # User is not a member of this team, return no results using SQLAlchemy's false()
9147 LOGGER.warning(f"User {user_email} attempted to filter resource IDs by team {team_id} but is not a member")
9148 query = query.where(false())
9149 else:
9150 # All Teams view: apply standard access conditions (owner, team, public)
9151 access_conditions = []
9152 access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user))
9153 if team_ids:
9154 access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"])))
9155 access_conditions.append(DbResource.visibility == "public")
9156 query = query.where(or_(*access_conditions))
9158 resource_ids = [row[0] for row in db.execute(query).all()]
9159 return {"resource_ids": resource_ids, "count": len(resource_ids)}
9162@admin_router.get("/resources/search", response_class=JSONResponse)
9163@require_permission("resources.read", allow_admin_bypass=False)
9164async def admin_search_resources(
9165 q: str = Query("", description="Search query"),
9166 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
9167 include_inactive: bool = False,
9168 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
9169 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
9170 team_id: Optional[str] = Depends(_validated_team_id_param),
9171 db: Session = Depends(get_db),
9172 user=Depends(get_current_user_with_permissions),
9173):
9174 """Search resources by name or description for selector search.
9176 Performs a case-insensitive search over resource names and descriptions
9177 and returns a limited list of matching resources suitable for selector
9178 UIs (id, name, description).
9180 Args:
9181 q (str): Search query string.
9182 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
9183 include_inactive (bool): When True include resources that are inactive.
9184 limit (int): Maximum number of results to return (bounded by the query parameter).
9185 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
9186 team_id (Optional[str]): Filter by team ID.
9187 db (Session): Database session (injected dependency).
9188 user: Authenticated user object from dependency injection.
9190 Returns:
9191 dict: A dictionary containing:
9192 - "resources": List[dict] where each dict has keys "id", "name", "description".
9193 - "count": int number of matched resources returned.
9194 """
9195 user_email = get_user_email(user)
9196 search_query = _normalize_search_query(q)
9197 normalized_tags = _normalize_tags_query(tags)
9198 tag_groups = _parse_tag_filter_groups(normalized_tags)
9199 if not search_query and not tag_groups:
9200 return _build_search_response(entity_key="resources", entity_type="resources", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)
9202 team_ids = await _get_user_team_ids(user, db)
9204 query = select(DbResource.id, DbResource.name, DbResource.description)
9206 # Apply gateway filter if provided
9207 if gateway_id:
9208 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
9209 if gateway_ids:
9210 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
9211 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
9212 if non_null_ids and null_requested:
9213 query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None)))
9214 LOGGER.debug(f"Filtering resource search by gateway IDs (including NULL): {non_null_ids} + NULL")
9215 elif null_requested:
9216 query = query.where(DbResource.gateway_id.is_(None))
9217 LOGGER.debug("Filtering resource search by NULL gateway_id")
9218 else:
9219 query = query.where(DbResource.gateway_id.in_(non_null_ids))
9220 LOGGER.debug(f"Filtering resource search by gateway IDs: {non_null_ids}")
9222 if not include_inactive:
9223 query = query.where(DbResource.enabled.is_(True))
9225 # Build access conditions
9226 # When team_id is specified, show ONLY items from that team (team-scoped view)
9227 # Otherwise, show all accessible items (All Teams view)
9228 if team_id:
9229 # Team-specific view: only show resources from the specified team
9230 if team_id in team_ids:
9231 # Apply visibility check: team/public resources + user's own resources (including private)
9232 team_access = [
9233 and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])),
9234 and_(DbResource.team_id == team_id, DbResource.owner_email == user_email),
9235 ]
9236 query = query.where(or_(*team_access))
9237 LOGGER.debug(f"Filtering resource search by team_id: {team_id}")
9238 else:
9239 # User is not a member of this team, return no results using SQLAlchemy's false()
9240 LOGGER.warning(f"User {user_email} attempted to filter resource search by team {team_id} but is not a member")
9241 query = query.where(false())
9242 else:
9243 # All Teams view: apply standard access conditions (owner, team, public)
9244 access_conditions = []
9245 access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user))
9246 if team_ids:
9247 access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"])))
9248 access_conditions.append(DbResource.visibility == "public")
9249 query = query.where(or_(*access_conditions))
9251 if search_query:
9252 search_conditions = [
9253 _like_contains(func.lower(DbResource.id), search_query),
9254 _like_contains(func.lower(DbResource.name), search_query),
9255 _like_contains(func.lower(coalesce(DbResource.uri, "")), search_query),
9256 _like_contains(func.lower(coalesce(DbResource.description, "")), search_query),
9257 ]
9258 query = query.where(or_(*search_conditions))
9260 query = _apply_tag_filter_groups(query, db, DbResource.tags, tag_groups)
9262 if search_query:
9263 query = query.order_by(
9264 case(
9265 (func.lower(DbResource.name).startswith(search_query), 1),
9266 else_=2,
9267 ),
9268 func.lower(DbResource.name),
9269 )
9270 else:
9271 query = query.order_by(func.lower(DbResource.name))
9272 query = query.limit(limit)
9274 results = db.execute(query).all()
9275 resources = []
9276 for row in results:
9277 resources.append({"id": row.id, "name": row.name, "description": row.description})
9279 return _build_search_response(entity_key="resources", entity_type="resources", items=resources, query=search_query, tags=normalized_tags, tag_groups=tag_groups)
9282@admin_router.get("/prompts/search", response_class=JSONResponse)
9283@require_permission("prompts.read", allow_admin_bypass=False)
9284async def admin_search_prompts(
9285 q: str = Query("", description="Search query"),
9286 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
9287 include_inactive: bool = False,
9288 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
9289 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
9290 team_id: Optional[str] = Depends(_validated_team_id_param),
9291 db: Session = Depends(get_db),
9292 user=Depends(get_current_user_with_permissions),
9293):
9294 """Search prompts by name or description for selector search.
9296 Performs a case-insensitive search over prompt names and descriptions
9297 and returns a limited list of matching prompts suitable for selector
9298 UIs (id, name, description).
9300 Args:
9301 q (str): Search query string.
9302 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
9303 include_inactive (bool): When True include prompts that are inactive.
9304 limit (int): Maximum number of results to return (bounded by the query parameter).
9305 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
9306 team_id (Optional[str]): Filter by team ID.
9307 db (Session): Database session (injected dependency).
9308 user: Authenticated user object from dependency injection.
9310 Returns:
9311 dict: A dictionary containing:
9312 - "prompts": List[dict] where each dict has keys "id", "name", "description".
9313 - "count": int number of matched prompts returned.
9314 """
9315 user_email = get_user_email(user)
9316 search_query = _normalize_search_query(q)
9317 normalized_tags = _normalize_tags_query(tags)
9318 tag_groups = _parse_tag_filter_groups(normalized_tags)
9319 if not search_query and not tag_groups:
9320 return _build_search_response(entity_key="prompts", entity_type="prompts", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)
9322 team_ids = await _get_user_team_ids(user, db)
9324 query = select(DbPrompt.id, DbPrompt.original_name, DbPrompt.display_name, DbPrompt.description)
9326 # Apply gateway filter if provided
9327 if gateway_id:
9328 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
9329 if gateway_ids:
9330 null_requested = any(gid.lower() == "null" for gid in gateway_ids)
9331 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
9332 if non_null_ids and null_requested:
9333 query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None)))
9334 LOGGER.debug(f"Filtering prompt search by gateway IDs (including NULL): {non_null_ids} + NULL")
9335 elif null_requested:
9336 query = query.where(DbPrompt.gateway_id.is_(None))
9337 LOGGER.debug("Filtering prompt search by NULL gateway_id")
9338 else:
9339 query = query.where(DbPrompt.gateway_id.in_(non_null_ids))
9340 LOGGER.debug(f"Filtering prompt search by gateway IDs: {non_null_ids}")
9342 if not include_inactive:
9343 query = query.where(DbPrompt.enabled.is_(True))
9345 # Build access conditions
9346 # When team_id is specified, show ONLY items from that team (team-scoped view)
9347 # Otherwise, show all accessible items (All Teams view)
9348 if team_id:
9349 # Team-specific view: only show prompts from the specified team
9350 if team_id in team_ids:
9351 # Apply visibility check: team/public resources + user's own resources (including private)
9352 team_access = [
9353 and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])),
9354 and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email),
9355 ]
9356 query = query.where(or_(*team_access))
9357 LOGGER.debug(f"Filtering prompt search by team_id: {team_id}")
9358 else:
9359 # User is not a member of this team, return no results using SQLAlchemy's false()
9360 LOGGER.warning(f"User {user_email} attempted to filter prompt search by team {team_id} but is not a member")
9361 query = query.where(false())
9362 else:
9363 # All Teams view: apply standard access conditions (owner, team, public)
9364 access_conditions = []
9365 access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user))
9366 if team_ids:
9367 access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"])))
9368 access_conditions.append(DbPrompt.visibility == "public")
9369 query = query.where(or_(*access_conditions))
9371 if search_query:
9372 search_conditions = [
9373 _like_contains(func.lower(DbPrompt.id), search_query),
9374 _like_contains(func.lower(DbPrompt.original_name), search_query),
9375 _like_contains(func.lower(coalesce(DbPrompt.display_name, "")), search_query),
9376 _like_contains(func.lower(coalesce(DbPrompt.description, "")), search_query),
9377 ]
9378 query = query.where(or_(*search_conditions))
9380 query = _apply_tag_filter_groups(query, db, DbPrompt.tags, tag_groups)
9382 if search_query:
9383 query = query.order_by(
9384 case(
9385 (func.lower(DbPrompt.original_name).startswith(search_query), 1),
9386 (func.lower(coalesce(DbPrompt.display_name, "")).startswith(search_query), 1),
9387 else_=2,
9388 ),
9389 func.lower(DbPrompt.original_name),
9390 )
9391 else:
9392 query = query.order_by(func.lower(DbPrompt.original_name))
9393 query = query.limit(limit)
9395 results = db.execute(query).all()
9396 prompts = []
9397 for row in results:
9398 prompts.append(
9399 {
9400 "id": row.id,
9401 "name": row.original_name,
9402 "original_name": row.original_name,
9403 "display_name": row.display_name,
9404 "description": row.description,
9405 }
9406 )
9408 return _build_search_response(entity_key="prompts", entity_type="prompts", items=prompts, query=search_query, tags=normalized_tags, tag_groups=tag_groups)
9411@admin_router.get("/tokens/partial", response_class=HTMLResponse)
9412@require_permission("tokens.read", allow_admin_bypass=False)
9413async def admin_tokens_partial_html(
9414 request: Request,
9415 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
9416 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
9417 include_inactive: bool = False,
9418 render: Optional[str] = Query(None),
9419 q: Optional[str] = Query(None, description="Search query for token name"),
9420 team_id: Optional[str] = Depends(_validated_team_id_param),
9421 db: Session = Depends(get_db),
9422 user=Depends(get_current_user_with_permissions),
9423):
9424 """Return paginated tokens HTML partials for the admin UI.
9426 This HTMX endpoint returns only the partial HTML used by the admin UI for
9427 API tokens. It supports two render modes:
9429 - default: full token cards + pagination controls
9430 - ``render="controls"``: return only pagination controls
9432 Args:
9433 request: FastAPI request object used by the template engine.
9434 page: Page number (1-indexed).
9435 per_page: Number of items per page (bounded by settings).
9436 include_inactive: If True, include inactive/expired tokens in results.
9437 render: Render mode; one of None or "controls".
9438 q: Search query string to filter tokens by name.
9439 team_id: Filter by team ID.
9440 db: Database session (dependency-injected).
9441 user: Authenticated user object from dependency injection.
9443 Returns:
9444 HTMLResponse: A rendered template response containing either the token
9445 cards partial or pagination controls depending on ``render``.
9446 """
9447 user_email = get_user_email(user)
9448 LOGGER.debug(f"User {user_email} requested tokens HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, q={q}, team_id={team_id})")
9450 # Normalize per_page within configured bounds
9451 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
9453 # Build base query: tokens owned by this user OR in user's teams
9454 token_service = TokenCatalogService(db)
9455 user_team_ids = await token_service.get_user_team_ids(user_email)
9457 conditions = [EmailApiToken.user_email == user_email]
9458 if user_team_ids:
9459 conditions.append(EmailApiToken.team_id.in_(user_team_ids))
9461 query = select(EmailApiToken).where(or_(*conditions))
9463 if team_id:
9464 query = query.where(EmailApiToken.team_id == team_id)
9466 if not include_inactive:
9467 query = query.where(and_(EmailApiToken.is_active.is_(True), or_(EmailApiToken.expires_at.is_(None), EmailApiToken.expires_at > utc_now())))
9469 # Apply search filter on name (case-insensitive)
9470 if q and isinstance(q, str):
9471 query = query.where(EmailApiToken.name.ilike(f"%{_escape_like(q.strip().lower())}%", escape="\\"))
9473 query = query.order_by(desc(EmailApiToken.created_at))
9475 # Build query params for pagination links
9476 query_params: Dict[str, Any] = {}
9477 if include_inactive:
9478 query_params["include_inactive"] = "true"
9479 if team_id:
9480 query_params["team_id"] = team_id
9481 if q and isinstance(q, str):
9482 query_params["q"] = q
9484 # Use unified pagination function
9485 paginated_result = await paginate_query(
9486 db=db,
9487 query=query,
9488 page=page,
9489 per_page=per_page,
9490 cursor=None,
9491 base_url=f"{settings.app_root_path}/admin/tokens/partial",
9492 query_params=query_params,
9493 use_cursor_threshold=False,
9494 )
9496 tokens_db = paginated_result["data"]
9497 pagination = paginated_result["pagination"]
9498 links = paginated_result["links"]
9500 base_url = f"{settings.app_root_path}/admin/tokens/partial"
9502 if render == "controls":
9503 db.commit()
9504 return request.app.state.templates.TemplateResponse(
9505 request,
9506 "pagination_controls.html",
9507 {
9508 "request": request,
9509 "pagination": pagination.model_dump(),
9510 "base_url": base_url,
9511 "hx_target": "#tokens-table",
9512 "hx_indicator": "#tokens-loading",
9513 "query_params": query_params,
9514 "root_path": request.scope.get("root_path", ""),
9515 },
9516 )
9518 # Build token data with revocation info and team names
9520 # Batch fetch team names
9521 team_ids_set = {t.team_id for t in tokens_db if t.team_id}
9522 team_map: Dict[str, str] = {}
9523 if team_ids_set:
9524 teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
9525 team_map = {team.id: team.name for team in teams}
9527 # Batch fetch revocation info (single query instead of N+1)
9528 revocation_map = await token_service.get_token_revocations_batch([t.jti for t in tokens_db])
9530 # Build token data list
9531 data = []
9532 for token in tokens_db:
9533 revocation_info = revocation_map.get(token.jti)
9534 data.append(
9535 {
9536 "id": token.id,
9537 "name": token.name,
9538 "description": token.description,
9539 "user_email": token.user_email,
9540 "team_id": token.team_id,
9541 "team_name": team_map.get(token.team_id) if token.team_id else None,
9542 "created_at": token.created_at,
9543 "expires_at": token.expires_at,
9544 "last_used": token.last_used,
9545 "is_active": token.is_active,
9546 "is_revoked": revocation_info is not None,
9547 "revoked_at": revocation_info.revoked_at if revocation_info else None,
9548 "revoked_by": revocation_info.revoked_by if revocation_info else None,
9549 "revocation_reason": revocation_info.reason if revocation_info else None,
9550 "tags": token.tags or [],
9551 "server_id": token.server_id,
9552 "resource_scopes": token.resource_scopes or [],
9553 "ip_restrictions": token.ip_restrictions or [],
9554 "time_restrictions": token.time_restrictions or {},
9555 "usage_limits": token.usage_limits or {},
9556 }
9557 )
9558 data = jsonable_encoder(data)
9559 for item in data:
9560 item["_json"] = orjson.dumps(item).decode()
9562 db.commit()
9564 return request.app.state.templates.TemplateResponse(
9565 request,
9566 "tokens_partial.html",
9567 {
9568 "request": request,
9569 "data": data,
9570 "pagination": pagination.model_dump(),
9571 "links": links.model_dump() if links else None,
9572 "root_path": request.scope.get("root_path", ""),
9573 "include_inactive": include_inactive,
9574 "team_id": team_id,
9575 },
9576 )
9579@admin_router.get("/tokens/search", response_class=JSONResponse)
9580@require_permission("tokens.read", allow_admin_bypass=False)
9581async def admin_search_tokens(
9582 q: str = Query("", description="Search query"),
9583 include_inactive: bool = False,
9584 limit: int = Query(settings.pagination_default_page_size, ge=1, le=100, description="Max results"),
9585 team_id: Optional[str] = Depends(_validated_team_id_param),
9586 db: Session = Depends(get_db),
9587 user=Depends(get_current_user_with_permissions),
9588):
9589 """Search API tokens by name.
9591 Args:
9592 q (str): Search query string to match against token names.
9593 include_inactive (bool): Whether to include inactive/revoked tokens.
9594 limit (int): Maximum number of results to return.
9595 team_id (Optional[str]): Filter by team ID.
9596 db (Session): Database session dependency.
9597 user: Current authenticated user.
9599 Returns:
9600 JSONResponse: List of matching tokens with basic info.
9601 """
9602 user_email = get_user_email(user)
9603 LOGGER.debug(f"User {user_email} searching tokens with query='{q}', include_inactive={include_inactive}, limit={limit}, team_id={team_id}")
9605 # Build base query: tokens owned by this user OR in user's teams
9606 token_service = TokenCatalogService(db)
9607 user_team_ids = await token_service.get_user_team_ids(user_email)
9609 conditions = [EmailApiToken.user_email == user_email]
9610 if user_team_ids:
9611 conditions.append(EmailApiToken.team_id.in_(user_team_ids))
9613 query = select(EmailApiToken).where(or_(*conditions))
9615 if team_id:
9616 query = query.where(EmailApiToken.team_id == team_id)
9618 if not include_inactive:
9619 query = query.where(and_(EmailApiToken.is_active.is_(True), or_(EmailApiToken.expires_at.is_(None), EmailApiToken.expires_at > utc_now())))
9621 # Apply search filter on name (case-insensitive)
9622 if q and isinstance(q, str):
9623 query = query.where(EmailApiToken.name.ilike(f"%{_escape_like(q.strip().lower())}%", escape="\\"))
9625 query = query.order_by(desc(EmailApiToken.created_at)).limit(limit)
9627 result = db.execute(query)
9628 tokens = result.scalars().all()
9630 # Batch fetch revocation info (single query instead of N+1)
9631 revocation_map = await token_service.get_token_revocations_batch([t.jti for t in tokens])
9633 token_data = []
9634 for token in tokens:
9635 revocation_info = revocation_map.get(token.jti)
9636 token_data.append(
9637 {
9638 "id": token.id,
9639 "name": token.name,
9640 "description": token.description,
9641 "user_email": token.user_email,
9642 "team_id": token.team_id,
9643 "created_at": token.created_at,
9644 "expires_at": token.expires_at,
9645 "last_used": token.last_used,
9646 "is_active": token.is_active,
9647 "is_revoked": revocation_info is not None,
9648 "tags": token.tags or [],
9649 "server_id": token.server_id,
9650 }
9651 )
9653 db.commit()
9654 return token_data
9657@admin_router.get("/a2a/partial", response_class=HTMLResponse)
9658@require_permission("a2a.read", allow_admin_bypass=False)
9659async def admin_a2a_partial_html(
9660 request: Request,
9661 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
9662 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
9663 q: str = Query("", description="Search query"),
9664 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
9665 include_inactive: bool = False,
9666 render: Optional[str] = Query(None),
9667 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
9668 team_id: Optional[str] = Depends(_validated_team_id_param),
9669 db: Session = Depends(get_db),
9670 user=Depends(get_current_user_with_permissions),
9671):
9672 """Return paginated a2a agents HTML partials for the admin UI.
9674 This HTMX endpoint returns only the partial HTML used by the admin UI for
9675 a2a agents. It supports three render modes:
9677 - default: full table partial (rows + controls)
9678 - ``render="controls"``: return only pagination controls
9679 - ``render="selector"``: return selector items for infinite scroll
9681 Args:
9682 request (Request): FastAPI request object used by the template engine.
9683 page (int): Page number (1-indexed).
9684 per_page (int): Number of items per page (bounded by settings).
9685 q (str): Free-text query string.
9686 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
9687 include_inactive (bool): If True, include inactive a2a agents in results.
9688 render (Optional[str]): Render mode; one of None, "controls", "selector".
9689 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
9690 team_id (Optional[str]): Filter by team ID.
9691 db (Session): Database session (dependency-injected).
9692 user: Authenticated user object from dependency injection.
9694 Returns:
9695 Union[HTMLResponse, TemplateResponse]: A rendered template response
9696 containing either the table partial, pagination controls, or selector
9697 items depending on ``render``. The response contains JSON-serializable
9698 encoded a2a agent data when templates expect it.
9699 """
9700 LOGGER.debug(
9701 f"User {get_user_email(user)} requested a2a_agents HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, gateway_id={gateway_id}, team_id={team_id})"
9702 )
9703 search_query = _normalize_search_query(q)
9704 normalized_tags = _normalize_tags_query(tags)
9705 tag_groups = _parse_tag_filter_groups(normalized_tags)
9706 # Normalize per_page within configured bounds
9707 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
9709 user_email = get_user_email(user)
9711 # Team scoping
9712 team_ids = await _get_user_team_ids(user, db)
9714 # Build base query
9715 query = select(DbA2AAgent)
9717 # Note: A2A agents don't have gateway_id field, they connect directly via endpoint_url
9718 # The gateway_id parameter is ignored for A2A agents
9720 if not include_inactive:
9721 query = query.where(DbA2AAgent.enabled.is_(True))
9723 # Build access conditions
9724 # When team_id is specified, show ONLY items from that team (team-scoped view)
9725 # Otherwise, show all accessible items (All Teams view)
9726 if team_id:
9727 # Team-specific view: only show a2a agents from the specified team
9728 if team_id in team_ids:
9729 # Apply visibility check: team/public resources + user's own resources (including private)
9730 team_access = [
9731 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])),
9732 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email),
9733 ]
9734 query = query.where(or_(*team_access))
9735 LOGGER.debug(f"Filtering a2a agents by team_id: {team_id}")
9736 else:
9737 # User is not a member of this team, return no results using SQLAlchemy's false()
9738 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
9739 query = query.where(false())
9740 else:
9741 # All Teams view: apply standard access conditions (owner, team, public)
9742 access_conditions = []
9743 access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user))
9744 if team_ids:
9745 access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"])))
9746 access_conditions.append(DbA2AAgent.visibility == "public")
9747 query = query.where(or_(*access_conditions))
9749 if search_query:
9750 query = query.where(
9751 or_(
9752 _like_contains(func.lower(DbA2AAgent.id), search_query),
9753 _like_contains(func.lower(DbA2AAgent.name), search_query),
9754 _like_contains(func.lower(coalesce(DbA2AAgent.endpoint_url, "")), search_query),
9755 _like_contains(func.lower(coalesce(DbA2AAgent.description, "")), search_query),
9756 )
9757 )
9759 query = _apply_tag_filter_groups(query, db, DbA2AAgent.tags, tag_groups)
9761 # Apply pagination ordering for cursor support
9762 query = query.order_by(desc(DbA2AAgent.created_at), desc(DbA2AAgent.id))
9764 # Build query params for pagination links
9765 query_params = {}
9766 if include_inactive:
9767 query_params["include_inactive"] = "true"
9768 if gateway_id:
9769 query_params["gateway_id"] = gateway_id
9770 if team_id:
9771 query_params["team_id"] = team_id
9772 if search_query:
9773 query_params["q"] = search_query
9774 if normalized_tags:
9775 query_params["tags"] = normalized_tags
9777 # Use unified pagination function
9778 root_path = request.scope.get("root_path", "")
9779 base_url = f"{root_path}/admin/a2a/partial"
9780 paginated_result = await paginate_query(
9781 db=db,
9782 query=query,
9783 page=page,
9784 per_page=per_page,
9785 cursor=None, # HTMX partials use page-based navigation
9786 base_url=base_url,
9787 query_params=query_params,
9788 use_cursor_threshold=False, # Disable auto-cursor switching for UI
9789 )
9791 # Extract paginated a2a_agents (DbA2AAgent objects)
9792 a2a_agents_db = paginated_result["data"]
9793 pagination = paginated_result["pagination"]
9794 links = paginated_result["links"]
9796 # Batch fetch team names for the a2a_agents to avoid N+1 queries
9797 team_ids_set = {p.team_id for p in a2a_agents_db if p.team_id}
9798 team_map = {}
9799 if team_ids_set:
9800 teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
9801 team_map = {team.id: team.name for team in teams}
9803 # Apply team names to DB objects before conversion
9804 for p in a2a_agents_db:
9805 p.team = team_map.get(p.team_id) if p.team_id else None
9807 # Batch convert to Pydantic models using a2a service
9808 # This eliminates the N+1 query problem from calling get_a2a_details() in a loop
9809 a2a_agents_pydantic = []
9810 failed_count = 0
9811 for a in a2a_agents_db:
9812 try:
9813 a2a_agents_pydantic.append(a2a_service.convert_agent_to_read(a, include_metrics=False))
9814 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
9815 failed_count += 1
9816 LOGGER.exception(f"Failed to convert a2a agent {getattr(a, 'id', 'unknown')} ({getattr(a, 'name', 'unknown')}): {e}")
9817 _adjust_pagination_for_conversion_failures(pagination, failed_count)
9818 data = jsonable_encoder(a2a_agents_pydantic)
9820 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
9821 db.commit()
9823 if render == "controls":
9824 return request.app.state.templates.TemplateResponse(
9825 request,
9826 "pagination_controls.html",
9827 {
9828 "request": request,
9829 "pagination": pagination.model_dump(),
9830 "base_url": base_url,
9831 "hx_target": "#agents-table-body",
9832 "hx_indicator": "#agents-loading",
9833 "query_params": query_params,
9834 "root_path": request.scope.get("root_path", ""),
9835 },
9836 )
9838 if render == "selector":
9839 return request.app.state.templates.TemplateResponse(
9840 request,
9841 "agents_selector_items.html",
9842 {
9843 "request": request,
9844 "data": data,
9845 "pagination": pagination.model_dump(),
9846 "root_path": request.scope.get("root_path", ""),
9847 "gateway_id": gateway_id,
9848 },
9849 )
9851 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
9852 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
9853 return request.app.state.templates.TemplateResponse(
9854 request,
9855 "agents_partial.html",
9856 {
9857 "request": request,
9858 "data": data,
9859 "pagination": pagination.model_dump(),
9860 "links": links.model_dump() if links else None,
9861 "root_path": request.scope.get("root_path", ""),
9862 "include_inactive": include_inactive,
9863 "query_params": query_params,
9864 "current_user_email": user_email,
9865 "is_admin": _is_admin,
9866 "user_team_roles": _team_roles,
9867 },
9868 )
9871@admin_router.get("/a2a/ids", response_class=JSONResponse)
9872@require_permission("a2a.read", allow_admin_bypass=False)
9873async def admin_get_all_agent_ids(
9874 include_inactive: bool = False,
9875 team_id: Optional[str] = Depends(_validated_team_id_param),
9876 db: Session = Depends(get_db),
9877 user=Depends(get_current_user_with_permissions),
9878):
9879 """Return all agent IDs accessible to the current user (select-all helper).
9881 This endpoint is used by UI "Select All" helpers to fetch only the IDs
9882 of a2a agents the requesting user can access (owner, team, or public).
9884 Args:
9885 include_inactive (bool): When True include a2a agents that are inactive.
9886 team_id (Optional[str]): Filter by team ID.
9887 db (Session): Database session (injected dependency).
9888 user: Authenticated user object from dependency injection.
9890 Returns:
9891 dict: A dictionary containing two keys:
9892 - "agent_ids": List[str] of accessible agent IDs.
9893 - "count": int number of IDs returned.
9894 """
9895 user_email = get_user_email(user)
9896 team_ids = await _get_user_team_ids(user, db)
9898 query = select(DbA2AAgent.id)
9900 if not include_inactive:
9901 query = query.where(DbA2AAgent.enabled.is_(True))
9903 # Build access conditions
9904 # When team_id is specified, show ONLY items from that team (team-scoped view)
9905 # Otherwise, show all accessible items (All Teams view)
9906 if team_id:
9907 if team_id in team_ids:
9908 # Apply visibility check: team/public resources + user's own resources (including private)
9909 team_access = [
9910 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])),
9911 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email),
9912 ]
9913 query = query.where(or_(*team_access))
9914 LOGGER.debug(f"Filtering A2A agent IDs by team_id: {team_id}")
9915 else:
9916 LOGGER.warning(f"User {user_email} attempted to filter A2A agent IDs by team {team_id} but is not a member")
9917 query = query.where(false())
9918 else:
9919 # All Teams view: apply standard access conditions (owner, team, public)
9920 access_conditions = []
9921 access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user))
9922 access_conditions.append(DbA2AAgent.visibility == "public")
9923 if team_ids:
9924 access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"])))
9925 query = query.where(or_(*access_conditions))
9927 agent_ids = [row[0] for row in db.execute(query).all()]
9928 return {"agent_ids": agent_ids, "count": len(agent_ids)}
9931@admin_router.get("/a2a/search", response_class=JSONResponse)
9932@require_permission("a2a.read", allow_admin_bypass=False)
9933async def admin_search_a2a_agents(
9934 q: str = Query("", description="Search query"),
9935 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
9936 include_inactive: bool = False,
9937 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
9938 team_id: Optional[str] = Depends(_validated_team_id_param),
9939 db: Session = Depends(get_db),
9940 user=Depends(get_current_user_with_permissions),
9941):
9942 """Search a2a agents by name or description for selector search.
9944 Performs a case-insensitive search over prompt names and descriptions
9945 and returns a limited list of matching a2a agents suitable for selector
9946 UIs (id, name, description).
9948 Args:
9949 q (str): Search query string.
9950 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
9951 include_inactive (bool): When True include a2a agents that are inactive.
9952 limit (int): Maximum number of results to return (bounded by the query parameter).
9953 team_id (Optional[str]): Filter by team ID.
9954 db (Session): Database session (injected dependency).
9955 user: Authenticated user object from dependency injection.
9957 Returns:
9958 dict: A dictionary containing:
9959 - "agents": List[dict] where each dict has keys "id", "name", "description".
9960 - "count": int number of matched a2a agents returned.
9961 """
9962 user_email = get_user_email(user)
9963 search_query = _normalize_search_query(q)
9964 normalized_tags = _normalize_tags_query(tags)
9965 tag_groups = _parse_tag_filter_groups(normalized_tags)
9966 if not search_query and not tag_groups:
9967 return _build_search_response(entity_key="agents", entity_type="agents", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)
9969 team_ids = await _get_user_team_ids(user, db)
9971 query = select(DbA2AAgent.id, DbA2AAgent.name, DbA2AAgent.endpoint_url, DbA2AAgent.description)
9973 if not include_inactive:
9974 query = query.where(DbA2AAgent.enabled.is_(True))
9976 # Build access conditions
9977 # When team_id is specified, show ONLY items from that team (team-scoped view)
9978 # Otherwise, show all accessible items (All Teams view)
9979 if team_id:
9980 if team_id in team_ids:
9981 # Apply visibility check: team/public resources + user's own resources (including private)
9982 team_access = [
9983 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])),
9984 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email),
9985 ]
9986 query = query.where(or_(*team_access))
9987 LOGGER.debug(f"Filtering A2A agent search by team_id: {team_id}")
9988 else:
9989 LOGGER.warning(f"User {user_email} attempted to filter A2A agent search by team {team_id} but is not a member")
9990 query = query.where(false())
9991 else:
9992 # All Teams view: apply standard access conditions (owner, team, public)
9993 access_conditions = []
9994 access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user))
9995 access_conditions.append(DbA2AAgent.visibility == "public")
9996 if team_ids:
9997 access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"])))
9998 query = query.where(or_(*access_conditions))
10000 if search_query:
10001 search_conditions = [
10002 _like_contains(func.lower(DbA2AAgent.id), search_query),
10003 _like_contains(func.lower(DbA2AAgent.name), search_query),
10004 _like_contains(func.lower(coalesce(DbA2AAgent.endpoint_url, "")), search_query),
10005 _like_contains(func.lower(coalesce(DbA2AAgent.description, "")), search_query),
10006 ]
10007 query = query.where(or_(*search_conditions))
10009 query = _apply_tag_filter_groups(query, db, DbA2AAgent.tags, tag_groups)
10011 if search_query:
10012 query = query.order_by(
10013 case(
10014 (func.lower(DbA2AAgent.name).startswith(search_query), 1),
10015 (func.lower(coalesce(DbA2AAgent.endpoint_url, "")).startswith(search_query), 1),
10016 else_=2,
10017 ),
10018 func.lower(DbA2AAgent.name),
10019 )
10020 else:
10021 query = query.order_by(func.lower(DbA2AAgent.name))
10022 query = query.limit(limit)
10024 results = db.execute(query).all()
10025 agents = []
10026 for row in results:
10027 agents.append(
10028 {
10029 "id": row.id,
10030 "name": row.name,
10031 "endpoint_url": row.endpoint_url,
10032 "description": row.description,
10033 }
10034 )
10036 return _build_search_response(entity_key="agents", entity_type="agents", items=agents, query=search_query, tags=normalized_tags, tag_groups=tag_groups)
10039@admin_router.get("/search", response_class=JSONResponse)
10040@require_permission("admin.dashboard", allow_admin_bypass=False)
10041async def admin_unified_search(
10042 q: str = Query("", description="Search query"),
10043 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"),
10044 entity_types: Optional[str] = Query(
10045 None,
10046 description="Comma-separated entity types to include (servers,gateways,tools,resources,prompts,agents,teams,users)",
10047 ),
10048 include_inactive: bool = False,
10049 limit: int = Query(8, ge=1, le=settings.pagination_max_page_size, description="Per-entity result limit"),
10050 limit_per_type: Optional[int] = Query(
10051 None,
10052 ge=1,
10053 le=settings.pagination_max_page_size,
10054 description="Optional alias for per-entity result limit",
10055 ),
10056 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"),
10057 team_id: Optional[str] = Depends(_validated_team_id_param),
10058 db: Session = Depends(get_db),
10059 user=Depends(get_current_user_with_permissions),
10060):
10061 """Unified search across primary admin entities.
10063 Args:
10064 q (str): Free-text search query.
10065 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
10066 entity_types (Optional[str]): Optional comma-separated entity type list.
10067 include_inactive (bool): Whether to include inactive entities.
10068 limit (int): Default per-entity limit for returned items.
10069 limit_per_type (Optional[int]): Optional alias overriding ``limit``.
10070 gateway_id (Optional[str]): Gateway filter for tools/resources/prompts.
10071 team_id (Optional[str]): Team scope filter.
10072 db (Session): Database session.
10073 user: Authenticated user context.
10075 Returns:
10076 dict[str, Any]: Grouped and flattened search results with metadata.
10078 Raises:
10079 HTTPException: If ``entity_types`` is provided but contains no supported values.
10080 """
10081 search_query = _normalize_search_query(q)
10082 normalized_tags = _normalize_tags_query(tags)
10083 normalized_entity_types = _normalize_tags_query(entity_types)
10084 tag_groups = _parse_tag_filter_groups(normalized_tags)
10086 supported_entity_types = ["servers", "gateways", "tools", "resources", "prompts", "agents", "teams", "users"]
10087 default_entity_types = ["servers", "gateways", "tools", "resources", "prompts", "agents", "teams"]
10088 selected_entity_types: list[str] = []
10089 if normalized_entity_types:
10090 for raw_entity_type in normalized_entity_types.split(","):
10091 candidate = raw_entity_type.strip().lower()
10092 if not candidate:
10093 continue
10094 if candidate == "a2a":
10095 candidate = "agents"
10096 if candidate in supported_entity_types and candidate not in selected_entity_types:
10097 selected_entity_types.append(candidate)
10098 else:
10099 selected_entity_types = default_entity_types.copy()
10101 users_explicitly_requested = bool(normalized_entity_types and "users" in selected_entity_types)
10102 if "users" in selected_entity_types:
10103 can_search_users = await _has_permission(db=db, user=user, permission="admin.user_management")
10104 if not can_search_users:
10105 selected_entity_types = [entity_type for entity_type in selected_entity_types if entity_type != "users"]
10106 if users_explicitly_requested and not selected_entity_types:
10107 raise HTTPException(status_code=403, detail="Insufficient permissions. Required: admin.user_management")
10109 if not selected_entity_types:
10110 raise HTTPException(status_code=400, detail="No valid entity_types requested")
10112 resolved_limit = _normalize_int_query(limit, 8)
10113 effective_limit = _normalize_int_query(limit_per_type, resolved_limit)
10114 effective_limit = max(1, min(effective_limit, settings.pagination_max_page_size))
10116 if not search_query and not tag_groups:
10117 return {
10118 "query": search_query,
10119 "tags": normalized_tags,
10120 "entity_types": selected_entity_types,
10121 "limit_per_type": effective_limit,
10122 "filters_applied": {"q": search_query, "tags": normalized_tags, "tag_groups": tag_groups},
10123 "results": {key: [] for key in selected_entity_types},
10124 "groups": [],
10125 "items": [],
10126 "count": 0,
10127 }
10129 async def _safe_entity_search(search_callable, empty_key: str, **kwargs: Any) -> dict[str, Any]:
10130 try:
10131 return await search_callable(**kwargs)
10132 except HTTPException as exc:
10133 if exc.status_code in {401, 403}:
10134 return {empty_key: [], "items": [], "count": 0}
10135 raise
10137 # Pre-fetch team IDs once and inject into the user context so that
10138 # individual search functions reuse them via _get_user_team_ids().
10139 _team_ids = await _get_user_team_ids(user, db)
10140 user = dict(user) # shallow copy to avoid mutating the caller's dict
10141 user["_cached_team_ids"] = _team_ids
10143 grouped_results: dict[str, list[dict[str, Any]]] = {entity_type: [] for entity_type in selected_entity_types}
10145 if "servers" in selected_entity_types:
10146 servers_result = await _safe_entity_search(
10147 admin_search_servers,
10148 "servers",
10149 q=search_query,
10150 tags=normalized_tags,
10151 include_inactive=include_inactive,
10152 limit=effective_limit,
10153 team_id=team_id,
10154 db=db,
10155 user=user,
10156 )
10157 grouped_results["servers"] = typing_cast(list[dict[str, Any]], servers_result.get("servers", servers_result.get("items", [])))
10159 if "gateways" in selected_entity_types:
10160 gateways_result = await _safe_entity_search(
10161 admin_search_gateways,
10162 "gateways",
10163 q=search_query,
10164 tags=normalized_tags,
10165 include_inactive=include_inactive,
10166 limit=effective_limit,
10167 team_id=team_id,
10168 db=db,
10169 user=user,
10170 )
10171 grouped_results["gateways"] = typing_cast(list[dict[str, Any]], gateways_result.get("gateways", gateways_result.get("items", [])))
10173 if "tools" in selected_entity_types:
10174 tools_result = await _safe_entity_search(
10175 admin_search_tools,
10176 "tools",
10177 q=search_query,
10178 tags=normalized_tags,
10179 include_inactive=include_inactive,
10180 limit=effective_limit,
10181 gateway_id=gateway_id,
10182 team_id=team_id,
10183 db=db,
10184 user=user,
10185 )
10186 grouped_results["tools"] = typing_cast(list[dict[str, Any]], tools_result.get("tools", tools_result.get("items", [])))
10188 if "resources" in selected_entity_types:
10189 resources_result = await _safe_entity_search(
10190 admin_search_resources,
10191 "resources",
10192 q=search_query,
10193 tags=normalized_tags,
10194 include_inactive=include_inactive,
10195 limit=effective_limit,
10196 gateway_id=gateway_id,
10197 team_id=team_id,
10198 db=db,
10199 user=user,
10200 )
10201 grouped_results["resources"] = typing_cast(list[dict[str, Any]], resources_result.get("resources", resources_result.get("items", [])))
10203 if "prompts" in selected_entity_types:
10204 prompts_result = await _safe_entity_search(
10205 admin_search_prompts,
10206 "prompts",
10207 q=search_query,
10208 tags=normalized_tags,
10209 include_inactive=include_inactive,
10210 limit=effective_limit,
10211 gateway_id=gateway_id,
10212 team_id=team_id,
10213 db=db,
10214 user=user,
10215 )
10216 grouped_results["prompts"] = typing_cast(list[dict[str, Any]], prompts_result.get("prompts", prompts_result.get("items", [])))
10218 if "agents" in selected_entity_types:
10219 agents_result = await _safe_entity_search(
10220 admin_search_a2a_agents,
10221 "agents",
10222 q=search_query,
10223 tags=normalized_tags,
10224 include_inactive=include_inactive,
10225 limit=effective_limit,
10226 team_id=team_id,
10227 db=db,
10228 user=user,
10229 )
10230 grouped_results["agents"] = typing_cast(list[dict[str, Any]], agents_result.get("agents", agents_result.get("items", [])))
10232 # Teams and users do not support tag filtering; only include when a text query exists.
10233 if "teams" in selected_entity_types and search_query:
10234 teams_result = await _safe_entity_search(
10235 admin_search_teams,
10236 "teams",
10237 q=search_query,
10238 include_inactive=include_inactive,
10239 limit=effective_limit,
10240 visibility=None,
10241 db=db,
10242 user=user,
10243 )
10244 if isinstance(teams_result, list):
10245 grouped_results["teams"] = typing_cast(list[dict[str, Any]], teams_result)
10246 else:
10247 grouped_results["teams"] = typing_cast(list[dict[str, Any]], teams_result.get("teams", teams_result.get("items", [])))
10249 if "users" in selected_entity_types and search_query:
10250 users_result = await _safe_entity_search(
10251 admin_search_users,
10252 "users",
10253 q=search_query,
10254 limit=effective_limit,
10255 db=db,
10256 user=user,
10257 )
10258 grouped_results["users"] = typing_cast(list[dict[str, Any]], users_result.get("users", users_result.get("items", [])))
10260 groups = []
10261 flat_items: list[dict[str, Any]] = []
10262 for entity_type in selected_entity_types:
10263 items = grouped_results.get(entity_type, [])
10264 groups.append({"entity_type": entity_type, "count": len(items), "items": items})
10265 for item in items:
10266 enriched_item = dict(item)
10267 enriched_item["entity_type"] = entity_type
10268 flat_items.append(enriched_item)
10270 return {
10271 "query": search_query,
10272 "tags": normalized_tags,
10273 "entity_types": selected_entity_types,
10274 "limit_per_type": effective_limit,
10275 "filters_applied": {"q": search_query, "tags": normalized_tags, "tag_groups": tag_groups},
10276 "results": grouped_results,
10277 "groups": groups,
10278 "items": flat_items,
10279 "count": len(flat_items),
10280 }
10283@admin_router.get("/tools/{tool_id}", response_model=ToolRead)
10284@require_permission("tools.read", allow_admin_bypass=False)
10285async def admin_get_tool(tool_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
10286 """
10287 Retrieve specific tool details for the admin UI.
10289 This endpoint fetches the details of a specific tool from the database
10290 by its ID. It provides access to all information about the tool for
10291 viewing and management purposes.
10293 Args:
10294 tool_id (str): The ID of the tool to retrieve.
10295 db (Session): Database session dependency.
10296 user (str): Authenticated user dependency.
10298 Returns:
10299 ToolRead: The tool details formatted with by_alias=True.
10301 Raises:
10302 HTTPException: If the tool is not found.
10303 Exception: For any other unexpected errors.
10305 Examples:
10306 >>> callable(admin_get_tool)
10307 True
10308 >>> admin_get_tool.__name__
10309 'admin_get_tool'
10310 """
10311 LOGGER.debug(f"User {get_user_email(user)} requested details for tool ID {tool_id}")
10312 _user_email = get_user_email(user)
10313 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
10314 _team_roles = _get_user_team_roles(db, _user_email) if not _is_admin else {}
10315 try:
10316 tool = await tool_service.get_tool(db, tool_id, requesting_user_email=_user_email, requesting_user_is_admin=_is_admin, requesting_user_team_roles=_team_roles)
10317 return tool.model_dump(by_alias=True)
10318 except ToolNotFoundError as e:
10319 raise HTTPException(status_code=404, detail=str(e))
10320 except Exception as e:
10321 # Catch any other unexpected errors and re-raise or log as needed
10322 LOGGER.error(f"Error getting tool {tool_id}: {e}")
10323 raise e # Re-raise for now, or return a 500 JSONResponse if preferred for API consistency
10326@admin_router.post("/tools/")
10327@admin_router.post("/tools")
10328@require_permission("tools.create", allow_admin_bypass=False)
10329async def admin_add_tool(
10330 request: Request,
10331 db: Session = Depends(get_db),
10332 user=Depends(get_current_user_with_permissions),
10333) -> JSONResponse:
10334 """
10335 Add a tool via the admin UI with error handling.
10337 Expects form fields:
10338 - name
10339 - url
10340 - description (optional)
10341 - requestType (mapped to request_type; defaults to "SSE")
10342 - integrationType (mapped to integration_type; defaults to "MCP")
10343 - headers (JSON string)
10344 - input_schema (JSON string)
10345 - output_schema (JSON string, optional)
10346 - jsonpath_filter (optional)
10347 - auth_type (optional)
10348 - auth_username (optional)
10349 - auth_password (optional)
10350 - auth_token (optional)
10351 - auth_header_key (optional)
10352 - auth_header_value (optional)
10354 Logs the raw form data and assembled tool_data for debugging.
10356 Args:
10357 request (Request): the FastAPI request object containing the form data.
10358 db (Session): the SQLAlchemy database session.
10359 user (str): identifier of the authenticated user.
10361 Returns:
10362 JSONResponse: a JSON response with `{"message": ..., "success": ...}` and an appropriate HTTP status code.
10364 Examples:
10365 >>> callable(admin_add_tool)
10366 True
10367 >>> admin_add_tool.__name__
10368 'admin_add_tool'
10369 """
10370 LOGGER.debug(f"User {get_user_email(user)} is adding a new tool")
10371 form = await request.form()
10372 LOGGER.debug(f"Received form data: {dict(form)}")
10373 integration_type = form.get("integrationType", "REST")
10374 request_type = form.get("requestType")
10375 visibility = str(form.get("visibility", "private"))
10377 if request_type is None:
10378 if integration_type == "REST":
10379 request_type = "GET" # or any valid REST method default
10380 elif integration_type == "MCP":
10381 request_type = "SSE"
10382 else:
10383 request_type = "GET"
10385 user_email = get_user_email(user)
10386 # Determine personal team for default assignment
10387 team_id = form.get("team_id", None)
10388 team_service = TeamManagementService(db)
10389 team_id = await team_service.verify_team_for_user(user_email, team_id)
10390 # Parse tags from comma-separated string
10391 tags_str = str(form.get("tags", ""))
10392 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
10393 # Safely parse potential JSON strings from form
10394 headers_raw = form.get("headers")
10395 input_schema_raw = form.get("input_schema")
10396 output_schema_raw = form.get("output_schema")
10397 annotations_raw = form.get("annotations")
10398 tool_data: dict[str, Any] = {
10399 "name": form.get("name"),
10400 "displayName": form.get("displayName"),
10401 "url": form.get("url"),
10402 "description": form.get("description"),
10403 "request_type": request_type,
10404 "integration_type": integration_type,
10405 "headers": orjson.loads(headers_raw if isinstance(headers_raw, str) and headers_raw else "{}"),
10406 "input_schema": orjson.loads(input_schema_raw if isinstance(input_schema_raw, str) and input_schema_raw else "{}"),
10407 "output_schema": (orjson.loads(output_schema_raw) if isinstance(output_schema_raw, str) and output_schema_raw else None),
10408 "annotations": orjson.loads(annotations_raw if isinstance(annotations_raw, str) and annotations_raw else "{}"),
10409 "jsonpath_filter": form.get("jsonpath_filter", ""),
10410 "auth_type": form.get("auth_type", ""),
10411 "auth_username": form.get("auth_username", ""),
10412 "auth_password": form.get("auth_password", ""),
10413 "auth_token": form.get("auth_token", ""),
10414 "auth_header_key": form.get("auth_header_key", ""),
10415 "auth_header_value": form.get("auth_header_value", ""),
10416 "tags": tags,
10417 "visibility": visibility,
10418 "team_id": team_id,
10419 "owner_email": user_email,
10420 "query_mapping": orjson.loads(form.get("query_mapping") or "{}"),
10421 "header_mapping": orjson.loads(form.get("header_mapping") or "{}"),
10422 "timeout_ms": int(form.get("timeout_ms")) if form.get("timeout_ms") and form.get("timeout_ms").strip() else None,
10423 "expose_passthrough": form.get("expose_passthrough", "true"),
10424 "allowlist": orjson.loads(form.get("allowlist") or "[]"),
10425 "plugin_chain_pre": orjson.loads(form.get("plugin_chain_pre") or "[]"),
10426 "plugin_chain_post": orjson.loads(form.get("plugin_chain_post") or "[]"),
10427 }
10428 LOGGER.debug(f"Tool data built: {tool_data}")
10429 try:
10430 tool = ToolCreate(**tool_data)
10431 LOGGER.debug(f"Validated tool data: {tool.model_dump(by_alias=True)}")
10433 # Extract creation metadata
10434 metadata = MetadataCapture.extract_creation_metadata(request, user)
10436 await tool_service.register_tool(
10437 db,
10438 tool,
10439 created_by=metadata["created_by"],
10440 created_from_ip=metadata["created_from_ip"],
10441 created_via=metadata["created_via"],
10442 created_user_agent=metadata["created_user_agent"],
10443 import_batch_id=metadata["import_batch_id"],
10444 federation_source=metadata["federation_source"],
10445 )
10446 return ORJSONResponse(
10447 content={"message": "Tool registered successfully!", "success": True},
10448 status_code=200,
10449 )
10450 except IntegrityError as ex:
10451 error_message = ErrorFormatter.format_database_error(ex)
10452 LOGGER.error(f"IntegrityError in admin_add_tool: {error_message}")
10453 return ORJSONResponse(status_code=409, content=error_message)
10454 except ToolNameConflictError as ex:
10455 LOGGER.error(f"ToolNameConflictError in admin_add_tool: {str(ex)}")
10456 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
10457 except ToolError as ex:
10458 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
10459 except ValidationError as ex: # This block should catch ValidationError
10460 LOGGER.error(f"ValidationError in admin_add_tool: {str(ex)}")
10461 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
10462 except Exception as ex:
10463 LOGGER.error(f"Unexpected error in admin_add_tool: {str(ex)}")
10464 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
10467@admin_router.post("/tools/{tool_id}/edit/", response_model=None)
10468@admin_router.post("/tools/{tool_id}/edit", response_model=None)
10469@require_permission("tools.update", allow_admin_bypass=False)
10470async def admin_edit_tool(
10471 tool_id: str,
10472 request: Request,
10473 db: Session = Depends(get_db),
10474 user=Depends(get_current_user_with_permissions),
10475) -> Response:
10476 """
10477 Edit a tool via the admin UI.
10479 Expects form fields:
10480 - name
10481 - displayName (optional)
10482 - url
10483 - description (optional)
10484 - requestType (to be mapped to request_type)
10485 - integrationType (to be mapped to integration_type)
10486 - headers (as a JSON string)
10487 - input_schema (as a JSON string)
10488 - output_schema (as a JSON string, optional)
10489 - jsonpathFilter (optional)
10490 - auth_type (optional, string: "basic", "bearer", or empty)
10491 - auth_username (optional, for basic auth)
10492 - auth_password (optional, for basic auth)
10493 - auth_token (optional, for bearer auth)
10494 - auth_header_key (optional, for headers auth)
10495 - auth_header_value (optional, for headers auth)
10497 Assembles the tool_data dictionary by remapping form keys into the
10498 snake-case keys expected by the schemas.
10500 Args:
10501 tool_id (str): The ID of the tool to edit.
10502 request (Request): FastAPI request containing form data.
10503 db (Session): Database session dependency.
10504 user (str): Authenticated user dependency.
10506 Returns:
10507 Response: A redirect response to the tools section of the admin
10508 dashboard with a status code of 303 (See Other), or a JSON response with
10509 an error message if the update fails.
10511 Examples:
10512 >>> callable(admin_edit_tool)
10513 True
10514 >>> admin_edit_tool.__name__
10515 'admin_edit_tool'
10516 """
10517 LOGGER.debug(f"User {get_user_email(user)} is editing tool ID {tool_id}")
10518 form = await request.form()
10519 # Parse tags from comma-separated string
10520 tags_str = str(form.get("tags", ""))
10521 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
10522 visibility = str(form.get("visibility", "private"))
10524 user_email = get_user_email(user)
10525 # Determine personal team for default assignment
10526 team_id = form.get("team_id", None)
10527 LOGGER.info(f"before Verifying team for user {user_email} with team_id {team_id}")
10528 team_service = TeamManagementService(db)
10529 team_id = await team_service.verify_team_for_user(user_email, team_id)
10531 headers_raw2 = form.get("headers")
10532 input_schema_raw2 = form.get("input_schema")
10533 output_schema_raw2 = form.get("output_schema")
10534 annotations_raw2 = form.get("annotations")
10536 tool_data: dict[str, Any] = {
10537 "name": form.get("name"),
10538 "displayName": form.get("displayName"),
10539 "custom_name": form.get("customName"),
10540 "url": form.get("url"),
10541 "description": form.get("description"),
10542 "headers": orjson.loads(headers_raw2 if isinstance(headers_raw2, str) and headers_raw2 else "{}"),
10543 "input_schema": orjson.loads(input_schema_raw2 if isinstance(input_schema_raw2, str) and input_schema_raw2 else "{}"),
10544 "output_schema": (orjson.loads(output_schema_raw2) if isinstance(output_schema_raw2, str) and output_schema_raw2 else None),
10545 "annotations": orjson.loads(annotations_raw2 if isinstance(annotations_raw2, str) and annotations_raw2 else "{}"),
10546 "jsonpath_filter": form.get("jsonpathFilter", ""),
10547 "auth_type": form.get("auth_type", ""),
10548 "auth_username": form.get("auth_username", ""),
10549 "auth_password": form.get("auth_password", ""),
10550 "auth_token": form.get("auth_token", ""),
10551 "auth_header_key": form.get("auth_header_key", ""),
10552 "auth_header_value": form.get("auth_header_value", ""),
10553 "tags": tags,
10554 "visibility": visibility,
10555 "owner_email": user_email,
10556 "team_id": team_id,
10557 }
10558 # Only include integration_type if it's provided (not disabled in form)
10559 if "integrationType" in form:
10560 tool_data["integration_type"] = form.get("integrationType")
10561 # Only include request_type if it's provided (not disabled in form)
10562 if "requestType" in form:
10563 tool_data["request_type"] = form.get("requestType")
10564 LOGGER.debug(f"Tool update data built: {tool_data}")
10565 try:
10566 tool = ToolUpdate(**tool_data) # Pydantic validation happens here
10568 # Get current tool to extract current version
10569 current_tool = db.get(DbTool, tool_id)
10570 current_version = getattr(current_tool, "version", 0) if current_tool else 0
10572 # Extract modification metadata
10573 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, current_version)
10575 await tool_service.update_tool(
10576 db,
10577 tool_id,
10578 tool,
10579 modified_by=mod_metadata["modified_by"],
10580 modified_from_ip=mod_metadata["modified_from_ip"],
10581 modified_via=mod_metadata["modified_via"],
10582 modified_user_agent=mod_metadata["modified_user_agent"],
10583 user_email=user_email,
10584 )
10585 return ORJSONResponse(content={"message": "Edit tool successfully", "success": True}, status_code=200)
10586 except PermissionError as e:
10587 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
10588 return ORJSONResponse(
10589 content={"message": str(e), "success": False},
10590 status_code=403,
10591 )
10592 except IntegrityError as ex:
10593 error_message = ErrorFormatter.format_database_error(ex)
10594 LOGGER.error(f"IntegrityError in admin_tool_resource: {error_message}")
10595 return ORJSONResponse(status_code=409, content=error_message)
10596 except ToolNameConflictError as ex:
10597 LOGGER.error(f"ToolNameConflictError in admin_edit_tool: {str(ex)}")
10598 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
10599 except ToolError as ex:
10600 LOGGER.error(f"ToolError in admin_edit_tool: {str(ex)}")
10601 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
10602 except ValidationError as ex: # Catch Pydantic validation errors
10603 LOGGER.error(f"ValidationError in admin_edit_tool: {str(ex)}")
10604 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
10605 except Exception as ex: # Generic catch-all for unexpected errors
10606 LOGGER.error(f"Unexpected error in admin_edit_tool: {str(ex)}")
10607 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
10610@admin_router.post("/tools/{tool_id}/delete")
10611@require_permission("tools.delete", allow_admin_bypass=False)
10612async def admin_delete_tool(tool_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
10613 """
10614 Delete a tool via the admin UI.
10616 This endpoint permanently removes a tool from the database using its ID.
10617 It is irreversible and should be used with caution. The operation is logged,
10618 and the user must be authenticated to access this route.
10620 Args:
10621 tool_id (str): The ID of the tool to delete.
10622 request (Request): FastAPI request object (not used directly, but required by route signature).
10623 db (Session): Database session dependency.
10624 user (str): Authenticated user dependency.
10626 Returns:
10627 RedirectResponse: A redirect response to the tools section of the admin
10628 dashboard with a status code of 303 (See Other).
10630 Examples:
10631 >>> callable(admin_delete_tool)
10632 True
10633 >>> admin_delete_tool.__name__
10634 'admin_delete_tool'
10635 """
10636 form = await request.form()
10637 is_inactive_checked = str(form.get("is_inactive_checked", "false"))
10638 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
10639 user_email = get_user_email(user)
10640 LOGGER.debug(f"User {user_email} is deleting tool ID {tool_id}")
10641 error_message = None
10642 try:
10643 await tool_service.delete_tool(db, tool_id, user_email=user_email, purge_metrics=purge_metrics)
10644 except PermissionError as e:
10645 LOGGER.warning(f"Permission denied for user {user_email} deleting tool {tool_id}: {e}")
10646 error_message = str(e)
10647 except Exception as e:
10648 LOGGER.error(f"Error deleting tool: {e}")
10649 error_message = "Failed to delete tool. Please try again."
10651 root_path = request.scope.get("root_path", "")
10653 # Build redirect URL with error message if present
10654 if error_message:
10655 error_param = f"?error={urllib.parse.quote(error_message)}"
10656 if is_inactive_checked.lower() == "true":
10657 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#tools", status_code=303)
10658 return RedirectResponse(f"{root_path}/admin/{error_param}#tools", status_code=303)
10660 if is_inactive_checked.lower() == "true":
10661 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#tools", status_code=303)
10662 return RedirectResponse(f"{root_path}/admin#tools", status_code=303)
10665@admin_router.post("/tools/{tool_id}/state")
10666@require_permission("tools.update", allow_admin_bypass=False)
10667async def admin_set_tool_state(
10668 tool_id: str,
10669 request: Request,
10670 db: Session = Depends(get_db),
10671 user=Depends(get_current_user_with_permissions),
10672) -> RedirectResponse:
10673 """
10674 Toggle a tool's active status via the admin UI.
10676 This endpoint processes a form request to activate or deactivate a tool.
10677 It expects a form field 'activate' with value "true" to activate the tool
10678 or "false" to deactivate it. The endpoint handles exceptions gracefully and
10679 logs any errors that might occur during the status toggle operation.
10681 Args:
10682 tool_id (str): The ID of the tool whose status to toggle.
10683 request (Request): FastAPI request containing form data with the 'activate' field.
10684 db (Session): Database session dependency.
10685 user (str): Authenticated user dependency.
10687 Returns:
10688 RedirectResponse: A redirect to the admin dashboard tools section with a
10689 status code of 303 (See Other).
10691 Examples:
10692 >>> callable(admin_set_tool_state)
10693 True
10694 >>> admin_set_tool_state.__name__
10695 'admin_set_tool_state'
10696 """
10697 error_message = None
10698 user_email = get_user_email(user)
10699 LOGGER.debug(f"User {user_email} is toggling tool ID {tool_id}")
10700 form = await request.form()
10701 activate = str(form.get("activate", "true")).lower() == "true"
10702 is_inactive_checked = str(form.get("is_inactive_checked", "false"))
10703 try:
10704 await tool_service.set_tool_state(db, tool_id, activate, reachable=activate, user_email=user_email)
10705 except PermissionError as e:
10706 LOGGER.warning(f"Permission denied for user {user_email} setting tool state {tool_id}: {e}")
10707 error_message = str(e)
10708 except ToolLockConflictError as e:
10709 LOGGER.warning(f"Lock conflict for user {user_email} setting tool {tool_id} state: {e}")
10710 error_message = "Tool is being modified by another request. Please try again."
10711 except Exception as e:
10712 LOGGER.error(f"Error setting tool state: {e}")
10713 error_message = "Failed to set tool state. Please try again."
10715 root_path = request.scope.get("root_path", "")
10717 # Build redirect URL with error message if present
10718 if error_message:
10719 error_param = f"?error={urllib.parse.quote(error_message)}"
10720 if is_inactive_checked.lower() == "true":
10721 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#tools", status_code=303)
10722 return RedirectResponse(f"{root_path}/admin/{error_param}#tools", status_code=303)
10724 if is_inactive_checked.lower() == "true":
10725 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#tools", status_code=303)
10726 return RedirectResponse(f"{root_path}/admin#tools", status_code=303)
10729@admin_router.get("/gateways/{gateway_id}", response_model=GatewayRead)
10730@require_permission("gateways.read", allow_admin_bypass=False)
10731async def admin_get_gateway(gateway_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
10732 """Get gateway details for the admin UI.
10734 Args:
10735 gateway_id: Gateway ID.
10736 db: Database session.
10737 user: Authenticated user.
10739 Returns:
10740 Gateway details.
10742 Raises:
10743 HTTPException: If the gateway is not found.
10744 Exception: For any other unexpected errors.
10746 Examples:
10747 >>> callable(admin_get_gateway)
10748 True
10749 >>> admin_get_gateway.__name__
10750 'admin_get_gateway'
10751 """
10752 LOGGER.debug(f"User {get_user_email(user)} requested details for gateway ID {gateway_id}")
10753 try:
10754 gateway = await gateway_service.get_gateway(db, gateway_id)
10755 return gateway.model_dump(by_alias=True)
10756 except GatewayNotFoundError as e:
10757 raise HTTPException(status_code=404, detail=str(e))
10758 except Exception as e:
10759 LOGGER.error(f"Error getting gateway {gateway_id}: {e}")
10760 raise e
10763@admin_router.post("/gateways")
10764@require_permission("gateways.create", allow_admin_bypass=False)
10765async def admin_add_gateway(request: Request, db: Session = Depends(get_db), user: dict[str, Any] = Depends(get_current_user_with_permissions)) -> JSONResponse:
10766 """Add a gateway via the admin UI.
10768 Expects form fields:
10769 - name
10770 - url
10771 - description (optional)
10772 - tags (optional, comma-separated)
10774 Args:
10775 request: FastAPI request containing form data.
10776 db: Database session.
10777 user: Authenticated user.
10779 Returns:
10780 A redirect response to the admin dashboard.
10782 Examples:
10783 >>> callable(admin_add_gateway)
10784 True
10785 >>> admin_add_gateway.__name__
10786 'admin_add_gateway'
10787 """
10788 LOGGER.debug(f"User {get_user_email(user)} is adding a new gateway")
10789 form = await request.form()
10790 try:
10791 # Parse tags from comma-separated string
10792 tags_str = str(form.get("tags", ""))
10793 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
10795 # Parse auth_headers JSON if present
10796 auth_headers_json = str(form.get("auth_headers"))
10797 auth_headers: list[dict[str, Any]] = []
10798 if auth_headers_json:
10799 try:
10800 auth_headers = orjson.loads(auth_headers_json)
10801 except (orjson.JSONDecodeError, ValueError):
10802 auth_headers = []
10804 # Parse OAuth configuration - support both JSON string and individual form fields
10805 oauth_config_json = str(form.get("oauth_config"))
10806 oauth_config: Optional[dict[str, Any]] = None
10808 LOGGER.info(f"DEBUG: oauth_config_json from form = '{oauth_config_json}'")
10809 LOGGER.info(f"DEBUG: Individual OAuth fields - grant_type='{form.get('oauth_grant_type')}', issuer='{form.get('oauth_issuer')}'")
10811 # Option 1: Pre-assembled oauth_config JSON (from API calls)
10812 if oauth_config_json and oauth_config_json != "None":
10813 try:
10814 oauth_config = orjson.loads(oauth_config_json)
10815 # Encrypt the client secret if present
10816 if oauth_config and "client_secret" in oauth_config:
10817 encryption = get_encryption_service(settings.auth_encryption_secret)
10818 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"])
10819 except (orjson.JSONDecodeError, ValueError) as e:
10820 LOGGER.error(f"Failed to parse OAuth config: {e}")
10821 oauth_config = None
10823 # Option 2: Assemble from individual UI form fields
10824 if not oauth_config:
10825 oauth_grant_type = str(form.get("oauth_grant_type", ""))
10826 oauth_issuer = str(form.get("oauth_issuer", ""))
10827 oauth_token_url = str(form.get("oauth_token_url", ""))
10828 oauth_authorization_url = str(form.get("oauth_authorization_url", ""))
10829 oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
10830 oauth_client_id = str(form.get("oauth_client_id", ""))
10831 oauth_client_secret = str(form.get("oauth_client_secret", ""))
10832 oauth_username = str(form.get("oauth_username", ""))
10833 oauth_password = str(form.get("oauth_password", ""))
10834 oauth_scopes_str = str(form.get("oauth_scopes", ""))
10836 # If any OAuth field is provided, assemble oauth_config
10837 if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]):
10838 oauth_config = {}
10840 if oauth_grant_type:
10841 oauth_config["grant_type"] = oauth_grant_type
10842 if oauth_issuer:
10843 oauth_config["issuer"] = oauth_issuer
10844 if oauth_token_url:
10845 oauth_config["token_url"] = oauth_token_url # OAuthManager expects 'token_url', not 'token_endpoint'
10846 if oauth_authorization_url:
10847 oauth_config["authorization_url"] = oauth_authorization_url # OAuthManager expects 'authorization_url', not 'authorization_endpoint'
10848 if oauth_redirect_uri:
10849 oauth_config["redirect_uri"] = oauth_redirect_uri
10850 if oauth_client_id:
10851 oauth_config["client_id"] = oauth_client_id
10852 if oauth_client_secret:
10853 # Encrypt the client secret
10854 encryption = get_encryption_service(settings.auth_encryption_secret)
10855 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret)
10857 # Add username and password for password grant type
10858 if oauth_username:
10859 oauth_config["username"] = oauth_username
10860 if oauth_password:
10861 oauth_config["password"] = oauth_password
10863 # Parse scopes (comma or space separated)
10864 if oauth_scopes_str:
10865 scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
10866 if scopes:
10867 oauth_config["scopes"] = scopes
10869 LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}")
10870 LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}")
10872 visibility = str(form.get("visibility", "private"))
10874 # Handle passthrough_headers
10875 passthrough_headers = str(form.get("passthrough_headers"))
10876 if passthrough_headers and passthrough_headers.strip():
10877 try:
10878 passthrough_headers = orjson.loads(passthrough_headers)
10879 except (orjson.JSONDecodeError, ValueError):
10880 # Fallback to comma-separated parsing
10881 passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
10882 else:
10883 passthrough_headers = None
10885 # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
10886 auth_type_from_form = str(form.get("auth_type", ""))
10887 LOGGER.info(f"DEBUG: auth_type from form: '{auth_type_from_form}', oauth_config present: {oauth_config is not None}")
10888 if oauth_config and not auth_type_from_form:
10889 auth_type_from_form = "oauth"
10890 LOGGER.info("✅ Auto-detected OAuth configuration, setting auth_type='oauth'")
10891 elif oauth_config and auth_type_from_form:
10892 LOGGER.info(f"✅ OAuth config present with explicit auth_type='{auth_type_from_form}'")
10894 ca_certificate: Optional[str] = None
10895 sig: Optional[str] = None
10897 # CA certificate(s) handled by JavaScript validation (supports single or multiple files)
10898 # JavaScript validates, orders (root→intermediate→leaf), and concatenates into hidden field
10899 if "ca_certificate" in form:
10900 ca_cert_value = form["ca_certificate"]
10901 if isinstance(ca_cert_value, str) and ca_cert_value.strip():
10902 ca_certificate = ca_cert_value.strip()
10903 LOGGER.info("✅ CA certificate(s) received and validated by frontend")
10905 if settings.enable_ed25519_signing:
10906 try:
10907 private_key_pem = settings.ed25519_private_key.get_secret_value()
10908 sig = sign_data(ca_certificate.encode(), private_key_pem)
10909 except Exception as e:
10910 LOGGER.error(f"Error signing CA certificate: {e}")
10911 sig = None
10912 raise RuntimeError("Failed to sign CA certificate") from e
10913 else:
10914 LOGGER.warning("⚠️ Ed25519 signing is disabled; CA certificate will be stored without signature")
10915 sig = None
10917 gateway = GatewayCreate(
10918 name=str(form["name"]),
10919 url=str(form["url"]),
10920 description=str(form.get("description")),
10921 tags=tags,
10922 transport=str(form.get("transport", "SSE")),
10923 auth_type=auth_type_from_form,
10924 auth_username=str(form.get("auth_username", "")),
10925 auth_password=str(form.get("auth_password", "")),
10926 auth_token=str(form.get("auth_token", "")),
10927 auth_header_key=str(form.get("auth_header_key", "")),
10928 auth_header_value=str(form.get("auth_header_value", "")),
10929 auth_headers=auth_headers if auth_headers else None,
10930 auth_query_param_key=str(form.get("auth_query_param_key", "")) or None,
10931 auth_query_param_value=str(form.get("auth_query_param_value", "")) or None,
10932 oauth_config=oauth_config,
10933 one_time_auth=form.get("one_time_auth", False),
10934 passthrough_headers=passthrough_headers,
10935 visibility=visibility,
10936 ca_certificate=ca_certificate,
10937 ca_certificate_sig=sig if sig else None,
10938 signing_algorithm="ed25519" if sig else None,
10939 )
10940 except KeyError as e:
10941 # Convert KeyError to ValidationError-like response
10942 return ORJSONResponse(content={"message": f"Missing required field: {e}", "success": False}, status_code=422)
10944 except ValidationError as ex:
10945 # --- Getting only the custom message from the ValueError ---
10946 error_ctx = [str(err["ctx"]["error"]) for err in ex.errors()]
10947 return ORJSONResponse(content={"success": False, "message": "; ".join(error_ctx)}, status_code=422)
10949 except RuntimeError as err:
10950 # --- Getting only the custom message from the RuntimeError ---
10951 error_ctx = [str(err)]
10952 return ORJSONResponse(content={"success": False, "message": "; ".join(error_ctx)}, status_code=422)
10954 user_email = get_user_email(user)
10955 team_id = form.get("team_id", None)
10957 team_service = TeamManagementService(db)
10958 team_id = await team_service.verify_team_for_user(user_email, team_id)
10960 try:
10961 # Extract creation metadata
10962 metadata = MetadataCapture.extract_creation_metadata(request, user)
10964 team_id_cast = typing_cast(Optional[str], team_id)
10965 await gateway_service.register_gateway(
10966 db,
10967 gateway,
10968 created_by=metadata["created_by"],
10969 created_from_ip=metadata["created_from_ip"],
10970 created_via=metadata["created_via"],
10971 created_user_agent=metadata["created_user_agent"],
10972 visibility=visibility,
10973 team_id=team_id_cast,
10974 owner_email=user_email,
10975 initialize_timeout=settings.httpx_admin_read_timeout,
10976 )
10978 # Provide specific guidance for OAuth Authorization Code flow
10979 message = "Gateway registered successfully!"
10980 if oauth_config and isinstance(oauth_config, dict) and oauth_config.get("grant_type") == "authorization_code":
10981 message = (
10982 "Gateway registered successfully! 🎉\n\n"
10983 "⚠️ IMPORTANT: This gateway uses OAuth Authorization Code flow.\n"
10984 "You must complete the OAuth authorization before tools will work:\n\n"
10985 "1. Go to the Gateways list\n"
10986 "2. Click the '🔐 Authorize' button for this gateway\n"
10987 "3. Complete the OAuth consent flow\n"
10988 "4. Return to the admin panel\n\n"
10989 "Tools will not work until OAuth authorization is completed."
10990 )
10991 return ORJSONResponse(
10992 content={"message": message, "success": True},
10993 status_code=200,
10994 )
10996 except GatewayConnectionError as ex:
10997 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=502)
10998 except GatewayDuplicateConflictError as ex:
10999 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
11000 except GatewayNameConflictError as ex:
11001 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
11002 except RuntimeError as ex:
11003 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
11004 except ValidationError as ex:
11005 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
11006 # NOTE: Pydantic's ValidationError subclasses ValueError, so ValidationError must be handled first.
11007 except ValueError as ex:
11008 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400)
11009 except IntegrityError as ex:
11010 return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409)
11011 except Exception as ex:
11012 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
11015# OAuth callback is now handled by the dedicated OAuth router at /oauth/callback
11016# This route has been removed to avoid conflicts with the complete implementation
11017@admin_router.post("/gateways/{gateway_id}/edit")
11018@require_permission("gateways.update", allow_admin_bypass=False)
11019async def admin_edit_gateway(
11020 gateway_id: str,
11021 request: Request,
11022 db: Session = Depends(get_db),
11023 user=Depends(get_current_user_with_permissions),
11024) -> JSONResponse:
11025 """Edit a gateway via the admin UI.
11027 Expects form fields:
11028 - name
11029 - url
11030 - description (optional)
11031 - tags (optional, comma-separated)
11033 Args:
11034 gateway_id: Gateway ID.
11035 request: FastAPI request containing form data.
11036 db: Database session.
11037 user: Authenticated user.
11039 Returns:
11040 A redirect response to the admin dashboard.
11042 Examples:
11043 >>> callable(admin_edit_gateway)
11044 True
11045 >>> admin_edit_gateway.__name__
11046 'admin_edit_gateway'
11047 """
11048 LOGGER.debug(f"User {get_user_email(user)} is editing gateway ID {gateway_id}")
11049 form = await request.form()
11050 try:
11051 # Parse tags from comma-separated string
11052 tags_str = str(form.get("tags", ""))
11053 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
11055 visibility = str(form.get("visibility", "private"))
11057 # Parse auth_headers JSON if present
11058 auth_headers_json = str(form.get("auth_headers"))
11059 auth_headers = []
11060 if auth_headers_json:
11061 try:
11062 auth_headers = orjson.loads(auth_headers_json)
11063 except (orjson.JSONDecodeError, ValueError):
11064 auth_headers = []
11066 # Handle passthrough_headers
11067 passthrough_headers = str(form.get("passthrough_headers"))
11068 if passthrough_headers and passthrough_headers.strip():
11069 try:
11070 passthrough_headers = orjson.loads(passthrough_headers)
11071 except (orjson.JSONDecodeError, ValueError):
11072 # Fallback to comma-separated parsing
11073 passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
11074 else:
11075 passthrough_headers = None
11077 # Parse OAuth configuration - support both JSON string and individual form fields
11078 oauth_config_json = str(form.get("oauth_config"))
11079 oauth_config: Optional[dict[str, Any]] = None
11081 # Option 1: Pre-assembled oauth_config JSON (from API calls)
11082 if oauth_config_json and oauth_config_json != "None":
11083 try:
11084 oauth_config = orjson.loads(oauth_config_json)
11085 # Encrypt the client secret if present and not empty
11086 if oauth_config and "client_secret" in oauth_config and oauth_config["client_secret"]:
11087 encryption = get_encryption_service(settings.auth_encryption_secret)
11088 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"])
11089 except (orjson.JSONDecodeError, ValueError) as e:
11090 LOGGER.error(f"Failed to parse OAuth config: {e}")
11091 oauth_config = None
11093 # Option 2: Assemble from individual UI form fields
11094 if not oauth_config:
11095 oauth_grant_type = str(form.get("oauth_grant_type", ""))
11096 oauth_issuer = str(form.get("oauth_issuer", ""))
11097 oauth_token_url = str(form.get("oauth_token_url", ""))
11098 oauth_authorization_url = str(form.get("oauth_authorization_url", ""))
11099 oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
11100 oauth_client_id = str(form.get("oauth_client_id", ""))
11101 oauth_client_secret = str(form.get("oauth_client_secret", ""))
11102 oauth_username = str(form.get("oauth_username", ""))
11103 oauth_password = str(form.get("oauth_password", ""))
11104 oauth_scopes_str = str(form.get("oauth_scopes", ""))
11106 # If any OAuth field is provided, assemble oauth_config
11107 if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]):
11108 oauth_config = {}
11110 if oauth_grant_type:
11111 oauth_config["grant_type"] = oauth_grant_type
11112 if oauth_issuer:
11113 oauth_config["issuer"] = oauth_issuer
11114 if oauth_token_url:
11115 oauth_config["token_url"] = oauth_token_url # OAuthManager expects 'token_url', not 'token_endpoint'
11116 if oauth_authorization_url:
11117 oauth_config["authorization_url"] = oauth_authorization_url # OAuthManager expects 'authorization_url', not 'authorization_endpoint'
11118 if oauth_redirect_uri:
11119 oauth_config["redirect_uri"] = oauth_redirect_uri
11120 if oauth_client_id:
11121 oauth_config["client_id"] = oauth_client_id
11122 if oauth_client_secret:
11123 # Encrypt the client secret
11124 encryption = get_encryption_service(settings.auth_encryption_secret)
11125 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret)
11127 # Add username and password for password grant type
11128 if oauth_username:
11129 oauth_config["username"] = oauth_username
11130 if oauth_password:
11131 oauth_config["password"] = oauth_password
11133 # Parse scopes (comma or space separated)
11134 if oauth_scopes_str:
11135 scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
11136 if scopes:
11137 oauth_config["scopes"] = scopes
11139 LOGGER.info(f"✅ Assembled OAuth config from UI form fields (edit): grant_type={oauth_grant_type}, issuer={oauth_issuer}")
11141 user_email = get_user_email(user)
11142 # Determine personal team for default assignment
11143 team_id_raw = form.get("team_id", None)
11144 team_id = str(team_id_raw) if team_id_raw is not None else None
11146 team_service = TeamManagementService(db)
11147 team_id = await team_service.verify_team_for_user(user_email, team_id)
11149 # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
11150 auth_type_from_form = str(form.get("auth_type", ""))
11151 if oauth_config and not auth_type_from_form:
11152 auth_type_from_form = "oauth"
11153 LOGGER.info("Auto-detected OAuth configuration in edit, setting auth_type='oauth'")
11155 gateway = GatewayUpdate( # Pydantic validation happens here
11156 name=str(form.get("name")),
11157 url=str(form["url"]),
11158 description=str(form.get("description")),
11159 transport=str(form.get("transport", "SSE")),
11160 tags=tags,
11161 auth_type=auth_type_from_form,
11162 auth_username=str(form.get("auth_username", "")),
11163 auth_password=str(form.get("auth_password", "")),
11164 auth_token=str(form.get("auth_token", "")),
11165 auth_header_key=str(form.get("auth_header_key", "")),
11166 auth_header_value=str(form.get("auth_header_value", "")),
11167 auth_value=str(form.get("auth_value", "")),
11168 auth_headers=auth_headers if auth_headers else None,
11169 auth_query_param_key=str(form.get("auth_query_param_key", "")) or None,
11170 auth_query_param_value=str(form.get("auth_query_param_value", "")) or None,
11171 one_time_auth=form.get("one_time_auth", False),
11172 passthrough_headers=passthrough_headers,
11173 oauth_config=oauth_config,
11174 visibility=visibility,
11175 owner_email=user_email,
11176 team_id=team_id,
11177 )
11179 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
11180 await gateway_service.update_gateway(
11181 db,
11182 gateway_id,
11183 gateway,
11184 modified_by=mod_metadata["modified_by"],
11185 modified_from_ip=mod_metadata["modified_from_ip"],
11186 modified_via=mod_metadata["modified_via"],
11187 modified_user_agent=mod_metadata["modified_user_agent"],
11188 user_email=user_email,
11189 )
11190 return ORJSONResponse(
11191 content={"message": "Gateway updated successfully!", "success": True},
11192 status_code=200,
11193 )
11194 except PermissionError as e:
11195 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
11196 return ORJSONResponse(
11197 content={"message": str(e), "success": False},
11198 status_code=403,
11199 )
11200 except Exception as ex:
11201 if isinstance(ex, GatewayConnectionError):
11202 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=502)
11203 if isinstance(ex, RuntimeError):
11204 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
11205 if isinstance(ex, ValidationError):
11206 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
11207 if isinstance(ex, IntegrityError):
11208 return ORJSONResponse(status_code=409, content=ErrorFormatter.format_database_error(ex))
11209 # NOTE: Pydantic's ValidationError subclasses ValueError, so ValidationError must be handled first.
11210 if isinstance(ex, ValueError):
11211 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400)
11212 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
11215@admin_router.post("/gateways/{gateway_id}/delete")
11216@require_permission("gateways.delete", allow_admin_bypass=False)
11217async def admin_delete_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
11218 """
11219 Delete a gateway via the admin UI.
11221 This endpoint removes a gateway from the database by its ID. The deletion is
11222 permanent and cannot be undone. It requires authentication and logs the
11223 operation for auditing purposes.
11225 Args:
11226 gateway_id (str): The ID of the gateway to delete.
11227 request (Request): FastAPI request object (not used directly but required by the route signature).
11228 db (Session): Database session dependency.
11229 user (str): Authenticated user dependency.
11231 Returns:
11232 RedirectResponse: A redirect response to the gateways section of the admin
11233 dashboard with a status code of 303 (See Other).
11235 Examples:
11236 >>> callable(admin_delete_gateway)
11237 True
11238 >>> admin_delete_gateway.__name__
11239 'admin_delete_gateway'
11240 """
11241 user_email = get_user_email(user)
11242 LOGGER.debug(f"User {user_email} is deleting gateway ID {gateway_id}")
11243 error_message = None
11244 try:
11245 await gateway_service.delete_gateway(db, gateway_id, user_email=user_email)
11246 except PermissionError as e:
11247 LOGGER.warning(f"Permission denied for user {user_email} deleting gateway {gateway_id}: {e}")
11248 error_message = str(e)
11249 except Exception as e:
11250 LOGGER.error(f"Error deleting gateway: {e}")
11251 error_message = "Failed to delete gateway. Please try again."
11253 form = await request.form()
11254 is_inactive_checked = str(form.get("is_inactive_checked", "false"))
11255 root_path = request.scope.get("root_path", "")
11257 # Build redirect URL with error message if present
11258 if error_message:
11259 error_param = f"?error={urllib.parse.quote(error_message)}"
11260 if is_inactive_checked.lower() == "true":
11261 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#gateways", status_code=303)
11262 return RedirectResponse(f"{root_path}/admin/{error_param}#gateways", status_code=303)
11264 if is_inactive_checked.lower() == "true":
11265 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#gateways", status_code=303)
11266 return RedirectResponse(f"{root_path}/admin#gateways", status_code=303)
11269@admin_router.get("/resources/test/{resource_uri:path}")
11270@require_permission("resources.read", allow_admin_bypass=False)
11271async def admin_test_resource(resource_uri: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
11272 """
11273 Test reading a resource by its URI for the admin UI.
11275 Args:
11276 resource_uri: The full resource URI (may include encoded characters).
11277 db: Database session dependency.
11278 user: Authenticated user with proper permissions.
11280 Returns:
11281 A dictionary containing the resolved resource content.
11283 Raises:
11284 HTTPException: If the resource is not found.
11285 Exception: For unexpected errors.
11287 Examples:
11288 >>> callable(admin_test_resource)
11289 True
11290 >>> admin_test_resource.__name__
11291 'admin_test_resource'
11292 """
11293 user_email = get_user_email(user)
11294 LOGGER.debug(f"User {user_email} requested details for resource ID {resource_uri}")
11296 # For admin UI, pass user email and token_teams=None
11297 # Since admin UI requires admin permissions, the user should have full access
11298 # via the admin bypass (is_admin + token_teams=None)
11299 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False
11301 try:
11302 # Admin users get unrestricted access (user_email=None, token_teams=None)
11303 # Non-admin users get team-based access (user_email=email, token_teams=None for lookup)
11304 resource_content = await resource_service.read_resource(
11305 db,
11306 resource_uri=resource_uri,
11307 user=None if is_admin else user_email,
11308 token_teams=None,
11309 )
11310 return {"content": resource_content}
11311 except ResourceNotFoundError as e:
11312 raise HTTPException(status_code=404, detail=str(e))
11313 except Exception as e:
11314 LOGGER.error(f"Error getting resource for {resource_uri}: {e}")
11315 raise e
11318@admin_router.get("/resources/{resource_id}")
11319@require_permission("resources.read", allow_admin_bypass=False)
11320async def admin_get_resource(resource_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
11321 """Get resource details for the admin UI.
11323 Args:
11324 resource_id: Resource ID.
11325 db: Database session.
11326 user: Authenticated user.
11328 Returns:
11329 A dictionary containing resource details.
11331 Raises:
11332 HTTPException: If the resource is not found.
11333 Exception: For any other unexpected errors.
11335 Examples:
11336 >>> callable(admin_get_resource)
11337 True
11338 >>> admin_get_resource.__name__
11339 'admin_get_resource'
11340 """
11341 LOGGER.debug(f"User {get_user_email(user)} requested details for resource ID {resource_id}")
11342 try:
11343 resource = await resource_service.get_resource_by_id(db, resource_id, include_inactive=True)
11344 # content = await resource_service.read_resource(db, resource_id=resource_id)
11345 return {"resource": resource.model_dump(by_alias=True)} # , "content": None}
11346 except ResourceNotFoundError as e:
11347 raise HTTPException(status_code=404, detail=str(e))
11348 except Exception as e:
11349 LOGGER.error(f"Error getting resource {resource_id}: {e}")
11350 raise e
11353@admin_router.post("/resources")
11354@require_permission("resources.create", allow_admin_bypass=False)
11355async def admin_add_resource(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Response:
11356 """
11357 Add a resource via the admin UI.
11359 Expects form fields:
11360 - uri
11361 - name
11362 - description (optional)
11363 - mime_type (optional)
11364 - content
11366 Args:
11367 request: FastAPI request containing form data.
11368 db: Database session.
11369 user: Authenticated user.
11371 Returns:
11372 A redirect response to the admin dashboard.
11374 Examples:
11375 >>> callable(admin_add_resource)
11376 True
11377 >>> admin_add_resource.__name__
11378 'admin_add_resource'
11379 """
11380 LOGGER.debug(f"User {get_user_email(user)} is adding a new resource")
11381 form = await request.form()
11383 # Parse tags from comma-separated string
11384 tags_str = str(form.get("tags", ""))
11385 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
11386 visibility = str(form.get("visibility", "public"))
11387 user_email = get_user_email(user)
11388 # Determine personal team for default assignment
11389 team_id = form.get("team_id", None)
11390 team_service = TeamManagementService(db)
11391 team_id = await team_service.verify_team_for_user(user_email, team_id)
11393 try:
11394 # Handle template field: convert empty string to None for optional field
11395 template = None
11396 template_value = form.get("uri_template")
11397 template = template_value if template_value else None
11398 template_value = form.get("uri_template")
11399 uri_value = form.get("uri")
11401 # Ensure uri_value is a string
11402 if isinstance(uri_value, str) and "{" in uri_value and "}" in uri_value:
11403 template = uri_value
11405 resource = ResourceCreate(
11406 uri=str(form["uri"]),
11407 name=str(form["name"]),
11408 description=str(form.get("description", "")),
11409 mime_type=str(form.get("mimeType", "")),
11410 uri_template=template,
11411 content=str(form["content"]),
11412 tags=tags,
11413 visibility=visibility,
11414 team_id=team_id,
11415 owner_email=user_email,
11416 )
11418 metadata = MetadataCapture.extract_creation_metadata(request, user)
11420 await resource_service.register_resource(
11421 db,
11422 resource,
11423 created_by=metadata["created_by"],
11424 created_from_ip=metadata["created_from_ip"],
11425 created_via=metadata["created_via"],
11426 created_user_agent=metadata["created_user_agent"],
11427 import_batch_id=metadata["import_batch_id"],
11428 federation_source=metadata["federation_source"],
11429 team_id=team_id,
11430 owner_email=user_email,
11431 visibility=visibility,
11432 )
11433 return ORJSONResponse(
11434 content={"message": "Add resource registered successfully!", "success": True},
11435 status_code=200,
11436 )
11437 except Exception as ex:
11438 # Roll back only when a transaction is active to avoid sqlite3 "no transaction" errors.
11439 try:
11440 active_transaction = db.get_transaction() if hasattr(db, "get_transaction") else None
11441 if db.is_active and active_transaction is not None:
11442 db.rollback()
11443 except (InvalidRequestError, OperationalError) as rollback_error:
11444 LOGGER.warning(
11445 "Rollback failed (ignoring for SQLite compatibility): %s",
11446 rollback_error,
11447 )
11449 if isinstance(ex, ValidationError):
11450 LOGGER.error(f"ValidationError in admin_add_resource: {ErrorFormatter.format_validation_error(ex)}")
11451 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
11452 if isinstance(ex, IntegrityError):
11453 error_message = ErrorFormatter.format_database_error(ex)
11454 LOGGER.error(f"IntegrityError in admin_add_resource: {error_message}")
11455 return ORJSONResponse(status_code=409, content=error_message)
11456 if isinstance(ex, ResourceURIConflictError):
11457 LOGGER.error(f"ResourceURIConflictError in admin_add_resource: {ex}")
11458 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
11459 LOGGER.error(f"Error in admin_add_resource: {ex}")
11460 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
11463@admin_router.post("/resources/{resource_id}/edit")
11464@require_permission("resources.update", allow_admin_bypass=False)
11465async def admin_edit_resource(
11466 resource_id: str,
11467 request: Request,
11468 db: Session = Depends(get_db),
11469 user=Depends(get_current_user_with_permissions),
11470) -> JSONResponse:
11471 """
11472 Edit a resource via the admin UI.
11474 Expects form fields:
11475 - name
11476 - description (optional)
11477 - mime_type (optional)
11478 - content
11480 Args:
11481 resource_id: Resource ID.
11482 request: FastAPI request containing form data.
11483 db: Database session.
11484 user: Authenticated user.
11486 Returns:
11487 JSONResponse: A JSON response indicating success or failure of the resource update operation.
11489 Examples:
11490 >>> callable(admin_edit_resource)
11491 True
11492 >>> admin_edit_resource.__name__
11493 'admin_edit_resource'
11494 """
11495 LOGGER.debug(f"User {get_user_email(user)} is editing resource ID {resource_id}")
11496 form = await request.form()
11497 LOGGER.info(f"Form data received for resource edit: {form}")
11498 visibility = str(form.get("visibility", "private"))
11499 # Parse tags from comma-separated string
11500 tags_str = str(form.get("tags", ""))
11501 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
11503 try:
11504 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
11505 resource = ResourceUpdate(
11506 uri=str(form.get("uri", "")),
11507 name=str(form.get("name", "")),
11508 description=str(form.get("description")),
11509 mime_type=str(form.get("mimeType")),
11510 content=str(form.get("content", "")),
11511 template=str(form.get("template")),
11512 tags=tags,
11513 visibility=visibility,
11514 )
11515 LOGGER.info(f"ResourceUpdate object created: {resource}")
11516 await resource_service.update_resource(
11517 db,
11518 resource_id,
11519 resource,
11520 modified_by=mod_metadata["modified_by"],
11521 modified_from_ip=mod_metadata["modified_from_ip"],
11522 modified_via=mod_metadata["modified_via"],
11523 modified_user_agent=mod_metadata["modified_user_agent"],
11524 user_email=get_user_email(user),
11525 )
11526 return ORJSONResponse(
11527 content={"message": "Resource updated successfully!", "success": True},
11528 status_code=200,
11529 )
11530 except PermissionError as e:
11531 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
11532 return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403)
11533 except Exception as ex:
11534 if isinstance(ex, ValidationError):
11535 LOGGER.error(f"ValidationError in admin_edit_resource: {ErrorFormatter.format_validation_error(ex)}")
11536 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
11537 if isinstance(ex, IntegrityError):
11538 error_message = ErrorFormatter.format_database_error(ex)
11539 LOGGER.error(f"IntegrityError in admin_edit_resource: {error_message}")
11540 return ORJSONResponse(status_code=409, content=error_message)
11541 if isinstance(ex, ResourceURIConflictError):
11542 LOGGER.error(f"ResourceURIConflictError in admin_edit_resource: {ex}")
11543 return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False})
11544 LOGGER.error(f"Error in admin_edit_resource: {ex}")
11545 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
11548@admin_router.post("/resources/{resource_id}/delete")
11549@require_permission("resources.delete", allow_admin_bypass=False)
11550async def admin_delete_resource(resource_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
11551 """
11552 Delete a resource via the admin UI.
11554 This endpoint permanently removes a resource from the database using its resource ID.
11555 The operation is irreversible and should be used with caution. It requires
11556 user authentication and logs the deletion attempt.
11558 Args:
11559 resource_id (str): The ID of the resource to delete.
11560 request (Request): FastAPI request object (not used directly but required by the route signature).
11561 db (Session): Database session dependency.
11562 user (str): Authenticated user dependency.
11564 Returns:
11565 RedirectResponse: A redirect response to the resources section of the admin
11566 dashboard with a status code of 303 (See Other).
11568 Examples:
11569 >>> callable(admin_delete_resource)
11570 True
11571 >>> admin_delete_resource.__name__
11572 'admin_delete_resource'
11573 """
11575 form = await request.form()
11576 is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
11577 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
11578 user_email = get_user_email(user)
11579 LOGGER.debug(f"User {get_user_email(user)} is deleting resource ID {resource_id}")
11580 error_message = None
11581 try:
11582 await resource_service.delete_resource(
11583 db, # Use endpoint's db session (user["db"] is now closed early)
11584 resource_id,
11585 user_email=user_email,
11586 purge_metrics=purge_metrics,
11587 )
11588 except PermissionError as e:
11589 LOGGER.warning(f"Permission denied for user {user_email} deleting resource {resource_id}: {e}")
11590 error_message = str(e)
11591 except Exception as e:
11592 LOGGER.error(f"Error deleting resource: {e}")
11593 error_message = "Failed to delete resource. Please try again."
11594 root_path = request.scope.get("root_path", "")
11596 # Build redirect URL with error message if present
11597 if error_message:
11598 error_param = f"?error={urllib.parse.quote(error_message)}"
11599 if is_inactive_checked.lower() == "true":
11600 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#resources", status_code=303)
11601 return RedirectResponse(f"{root_path}/admin/{error_param}#resources", status_code=303)
11603 if is_inactive_checked.lower() == "true":
11604 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#resources", status_code=303)
11605 return RedirectResponse(f"{root_path}/admin#resources", status_code=303)
11608@admin_router.post("/resources/{resource_id}/state")
11609@require_permission("resources.update", allow_admin_bypass=False)
11610async def admin_set_resource_state(
11611 resource_id: str,
11612 request: Request,
11613 db: Session = Depends(get_db),
11614 user=Depends(get_current_user_with_permissions),
11615) -> RedirectResponse:
11616 """
11617 Toggle a resource's active status via the admin UI.
11619 This endpoint processes a form request to activate or deactivate a resource.
11620 It expects a form field 'activate' with value "true" to activate the resource
11621 or "false" to deactivate it. The endpoint handles exceptions gracefully and
11622 logs any errors that might occur during the status toggle operation.
11624 Args:
11625 resource_id (str): The ID of the resource whose status to toggle.
11626 request (Request): FastAPI request containing form data with the 'activate' field.
11627 db (Session): Database session dependency.
11628 user (str): Authenticated user dependency.
11630 Returns:
11631 RedirectResponse: A redirect to the admin dashboard resources section with a
11632 status code of 303 (See Other).
11634 Examples:
11635 >>> callable(admin_set_resource_state)
11636 True
11637 >>> admin_set_resource_state.__name__
11638 'admin_set_resource_state'
11639 """
11640 user_email = get_user_email(user)
11641 LOGGER.debug(f"User {user_email} is toggling resource ID {resource_id}")
11642 form = await request.form()
11643 error_message = None
11644 activate = str(form.get("activate", "true")).lower() == "true"
11645 is_inactive_checked = str(form.get("is_inactive_checked", "false"))
11646 try:
11647 await resource_service.set_resource_state(db, resource_id, activate, user_email=user_email)
11648 except PermissionError as e:
11649 LOGGER.warning(f"Permission denied for user {user_email} setting resource state {resource_id}: {e}")
11650 error_message = str(e)
11651 except Exception as e:
11652 LOGGER.error(f"Error setting resource state: {e}")
11653 error_message = "Failed to set resource state. Please try again."
11655 root_path = request.scope.get("root_path", "")
11657 # Build redirect URL with error message if present
11658 if error_message:
11659 error_param = f"?error={urllib.parse.quote(error_message)}"
11660 if is_inactive_checked.lower() == "true":
11661 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#resources", status_code=303)
11662 return RedirectResponse(f"{root_path}/admin/{error_param}#resources", status_code=303)
11664 if is_inactive_checked.lower() == "true":
11665 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#resources", status_code=303)
11666 return RedirectResponse(f"{root_path}/admin#resources", status_code=303)
11669@admin_router.get("/prompts/{prompt_id}")
11670@require_permission("prompts.read", allow_admin_bypass=False)
11671async def admin_get_prompt(prompt_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
11672 """Get prompt details for the admin UI.
11674 Args:
11675 prompt_id: Prompt ID.
11676 db: Database session.
11677 user: Authenticated user.
11679 Returns:
11680 A dictionary with prompt details.
11682 Raises:
11683 HTTPException: If the prompt is not found.
11684 Exception: For any other unexpected errors.
11686 Examples:
11687 >>> callable(admin_get_prompt)
11688 True
11689 >>> admin_get_prompt.__name__
11690 'admin_get_prompt'
11691 """
11692 LOGGER.info(f"User {get_user_email(user)} requested details for prompt ID {prompt_id}")
11693 try:
11694 prompt_details = await prompt_service.get_prompt_details(db, prompt_id)
11695 prompt = PromptRead.model_validate(prompt_details)
11696 return prompt.model_dump(by_alias=True)
11697 except PromptNotFoundError as e:
11698 raise HTTPException(status_code=404, detail=str(e))
11699 except Exception as e:
11700 LOGGER.error(f"Error getting prompt {prompt_id}: {e}")
11701 raise
11704@admin_router.post("/prompts")
11705@require_permission("prompts.create", allow_admin_bypass=False)
11706async def admin_add_prompt(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> JSONResponse:
11707 """Add a prompt via the admin UI.
11709 Expects form fields:
11710 - name
11711 - description (optional)
11712 - template
11713 - arguments (as a JSON string representing a list)
11715 Args:
11716 request: FastAPI request containing form data.
11717 db: Database session.
11718 user: Authenticated user.
11720 Returns:
11721 A redirect response to the admin dashboard.
11723 Examples:
11724 >>> callable(admin_add_prompt)
11725 True
11726 >>> admin_add_prompt.__name__
11727 'admin_add_prompt'
11728 """
11729 LOGGER.debug(f"User {get_user_email(user)} is adding a new prompt")
11730 form = await request.form()
11731 visibility = str(form.get("visibility", "private"))
11732 user_email = get_user_email(user)
11733 # Determine personal team for default assignment
11734 team_id = form.get("team_id", None)
11735 team_service = TeamManagementService(db)
11736 team_id = await team_service.verify_team_for_user(user_email, team_id)
11738 # Parse tags from comma-separated string
11739 tags_str = str(form.get("tags", ""))
11740 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
11742 try:
11743 args_json = "[]"
11744 args_value = form.get("arguments")
11745 if isinstance(args_value, str) and args_value.strip():
11746 args_json = args_value
11747 arguments = orjson.loads(args_json)
11748 prompt = PromptCreate(
11749 name=str(form["name"]),
11750 display_name=str(form.get("display_name") or form["name"]),
11751 description=str(form.get("description")),
11752 template=str(form["template"]),
11753 arguments=arguments,
11754 tags=tags,
11755 visibility=visibility,
11756 team_id=team_id,
11757 owner_email=user_email,
11758 )
11759 # Extract creation metadata
11760 metadata = MetadataCapture.extract_creation_metadata(request, user)
11762 await prompt_service.register_prompt(
11763 db,
11764 prompt,
11765 created_by=metadata["created_by"],
11766 created_from_ip=metadata["created_from_ip"],
11767 created_via=metadata["created_via"],
11768 created_user_agent=metadata["created_user_agent"],
11769 import_batch_id=metadata["import_batch_id"],
11770 federation_source=metadata["federation_source"],
11771 team_id=team_id,
11772 owner_email=user_email,
11773 visibility=visibility,
11774 )
11775 return ORJSONResponse(
11776 content={"message": "Prompt registered successfully!", "success": True},
11777 status_code=200,
11778 )
11779 except Exception as ex:
11780 if isinstance(ex, ValidationError):
11781 LOGGER.error(f"ValidationError in admin_add_prompt: {ErrorFormatter.format_validation_error(ex)}")
11782 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
11783 if isinstance(ex, IntegrityError):
11784 error_message = ErrorFormatter.format_database_error(ex)
11785 LOGGER.error(f"IntegrityError in admin_add_prompt: {error_message}")
11786 return ORJSONResponse(status_code=409, content=error_message)
11787 if isinstance(ex, PromptNameConflictError):
11788 LOGGER.error(f"PromptNameConflictError in admin_add_prompt: {ex}")
11789 return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False})
11790 LOGGER.error(f"Error in admin_add_prompt: {ex}")
11791 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
11794@admin_router.post("/prompts/{prompt_id}/edit")
11795@require_permission("prompts.update", allow_admin_bypass=False)
11796async def admin_edit_prompt(
11797 prompt_id: str,
11798 request: Request,
11799 db: Session = Depends(get_db),
11800 user=Depends(get_current_user_with_permissions),
11801) -> JSONResponse:
11802 """Edit a prompt via the admin UI.
11804 Expects form fields:
11805 - name
11806 - description (optional)
11807 - template
11808 - arguments (as a JSON string representing a list)
11810 Args:
11811 prompt_id: Prompt ID.
11812 request: FastAPI request containing form data.
11813 db: Database session.
11814 user: Authenticated user.
11816 Returns:
11817 JSONResponse: A JSON response indicating success or failure of the server update operation.
11819 Examples:
11820 >>> callable(admin_edit_prompt)
11821 True
11822 >>> admin_edit_prompt.__name__
11823 'admin_edit_prompt'
11824 """
11825 LOGGER.debug(f"User {get_user_email(user)} is editing prompt {prompt_id}")
11826 form = await request.form()
11828 visibility = str(form.get("visibility", "private"))
11829 user_email = get_user_email(user)
11830 # Determine personal team for default assignment
11831 team_id = form.get("team_id", None)
11832 LOGGER.info(f"befor Verifying team for user {user_email} with team_id {team_id}")
11833 team_service = TeamManagementService(db)
11834 team_id = await team_service.verify_team_for_user(user_email, team_id)
11835 LOGGER.info(f"Verifying team for user {user_email} with team_id {team_id}")
11837 args_json: str = str(form.get("arguments")) or "[]"
11838 arguments = orjson.loads(args_json)
11839 # Parse tags from comma-separated string
11840 tags_str = str(form.get("tags", ""))
11841 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
11842 try:
11843 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
11844 prompt = PromptUpdate(
11845 custom_name=str(form.get("customName") or form.get("name")),
11846 display_name=str(form.get("displayName") or form.get("display_name") or form.get("name")),
11847 description=str(form.get("description")),
11848 template=str(form["template"]),
11849 arguments=arguments,
11850 tags=tags,
11851 visibility=visibility,
11852 team_id=team_id,
11853 owner_email=user_email,
11854 )
11855 await prompt_service.update_prompt(
11856 db,
11857 prompt_id,
11858 prompt,
11859 modified_by=mod_metadata["modified_by"],
11860 modified_from_ip=mod_metadata["modified_from_ip"],
11861 modified_via=mod_metadata["modified_via"],
11862 modified_user_agent=mod_metadata["modified_user_agent"],
11863 user_email=user_email,
11864 )
11865 return ORJSONResponse(
11866 content={"message": "Prompt updated successfully!", "success": True},
11867 status_code=200,
11868 )
11869 except PermissionError as e:
11870 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
11871 return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403)
11872 except Exception as ex:
11873 if isinstance(ex, ValidationError):
11874 LOGGER.error(f"ValidationError in admin_edit_prompt: {ErrorFormatter.format_validation_error(ex)}")
11875 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
11876 if isinstance(ex, IntegrityError):
11877 error_message = ErrorFormatter.format_database_error(ex)
11878 LOGGER.error(f"IntegrityError in admin_edit_prompt: {error_message}")
11879 return ORJSONResponse(status_code=409, content=error_message)
11880 if isinstance(ex, PromptNameConflictError):
11881 LOGGER.error(f"PromptNameConflictError in admin_edit_prompt: {ex}")
11882 return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False})
11883 LOGGER.error(f"Error in admin_edit_prompt: {ex}")
11884 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
11887@admin_router.post("/prompts/{prompt_id}/delete")
11888@require_permission("prompts.delete", allow_admin_bypass=False)
11889async def admin_delete_prompt(prompt_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
11890 """
11891 Delete a prompt via the admin UI.
11893 This endpoint permanently deletes a prompt from the database using its ID.
11894 Deletion is irreversible and requires authentication. All actions are logged
11895 for administrative auditing.
11897 Args:
11898 prompt_id (str): The ID of the prompt to delete.
11899 request (Request): FastAPI request object (not used directly but required by the route signature).
11900 db (Session): Database session dependency.
11901 user (str): Authenticated user dependency.
11903 Returns:
11904 RedirectResponse: A redirect response to the prompts section of the admin
11905 dashboard with a status code of 303 (See Other).
11907 Examples:
11908 >>> callable(admin_delete_prompt)
11909 True
11910 >>> admin_delete_prompt.__name__
11911 'admin_delete_prompt'
11912 """
11913 form = await request.form()
11914 is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
11915 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
11916 user_email = get_user_email(user)
11917 LOGGER.info(f"User {get_user_email(user)} is deleting prompt id {prompt_id}")
11918 error_message = None
11919 try:
11920 await prompt_service.delete_prompt(db, prompt_id, user_email=user_email, purge_metrics=purge_metrics)
11921 except PermissionError as e:
11922 LOGGER.warning(f"Permission denied for user {user_email} deleting prompt {prompt_id}: {e}")
11923 error_message = str(e)
11924 except Exception as e:
11925 LOGGER.error(f"Error deleting prompt: {e}")
11926 error_message = "Failed to delete prompt. Please try again."
11927 root_path = request.scope.get("root_path", "")
11929 # Build redirect URL with error message if present
11930 if error_message:
11931 error_param = f"?error={urllib.parse.quote(error_message)}"
11932 if is_inactive_checked.lower() == "true":
11933 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#prompts", status_code=303)
11934 return RedirectResponse(f"{root_path}/admin/{error_param}#prompts", status_code=303)
11936 if is_inactive_checked.lower() == "true":
11937 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#prompts", status_code=303)
11938 return RedirectResponse(f"{root_path}/admin#prompts", status_code=303)
11941@admin_router.post("/prompts/{prompt_id}/state")
11942@require_permission("prompts.update", allow_admin_bypass=False)
11943async def admin_set_prompt_state(
11944 prompt_id: str,
11945 request: Request,
11946 db: Session = Depends(get_db),
11947 user=Depends(get_current_user_with_permissions),
11948) -> RedirectResponse:
11949 """
11950 Toggle a prompt's active status via the admin UI.
11952 This endpoint processes a form request to activate or deactivate a prompt.
11953 It expects a form field 'activate' with value "true" to activate the prompt
11954 or "false" to deactivate it. The endpoint handles exceptions gracefully and
11955 logs any errors that might occur during the status toggle operation.
11957 Args:
11958 prompt_id (str): The ID of the prompt whose status to toggle.
11959 request (Request): FastAPI request containing form data with the 'activate' field.
11960 db (Session): Database session dependency.
11961 user (str): Authenticated user dependency.
11963 Returns:
11964 RedirectResponse: A redirect to the admin dashboard prompts section with a
11965 status code of 303 (See Other).
11967 Examples:
11968 >>> callable(admin_set_prompt_state)
11969 True
11970 >>> admin_set_prompt_state.__name__
11971 'admin_set_prompt_state'
11972 """
11973 user_email = get_user_email(user)
11974 LOGGER.debug(f"User {user_email} is toggling prompt ID {prompt_id}")
11975 error_message = None
11976 form = await request.form()
11977 activate: bool = str(form.get("activate", "true")).lower() == "true"
11978 is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
11979 try:
11980 await prompt_service.set_prompt_state(db, prompt_id, activate, user_email=user_email)
11981 except PermissionError as e:
11982 LOGGER.warning(f"Permission denied for user {user_email} setting prompt state {prompt_id}: {e}")
11983 error_message = str(e)
11984 except Exception as e:
11985 LOGGER.error(f"Error setting prompt state: {e}")
11986 error_message = "Failed to set prompt state. Please try again."
11988 root_path = request.scope.get("root_path", "")
11990 # Build redirect URL with error message if present
11991 if error_message:
11992 error_param = f"?error={urllib.parse.quote(error_message)}"
11993 if is_inactive_checked.lower() == "true":
11994 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#prompts", status_code=303)
11995 return RedirectResponse(f"{root_path}/admin/{error_param}#prompts", status_code=303)
11997 if is_inactive_checked.lower() == "true":
11998 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#prompts", status_code=303)
11999 return RedirectResponse(f"{root_path}/admin#prompts", status_code=303)
12002@admin_router.get("/roots/export")
12003@require_permission("admin.system_config", allow_admin_bypass=False)
12004async def admin_export_root(
12005 uri: str,
12006 user=Depends(get_current_user_with_permissions),
12007):
12008 """
12009 Export a single root configuration as JSON.
12011 Args:
12012 uri: Root URI to export (query parameter)
12013 user: Authenticated user
12015 Returns:
12016 JSON file download with root configuration
12018 Raises:
12019 HTTPException: If root not found or export fails
12020 """
12021 try:
12022 LOGGER.info(f"Admin user {get_user_email(user)} requested root export for URI: {uri}")
12024 # Get the root by URI
12025 root = await root_service.get_root_by_uri(uri)
12027 # Extract username from user
12028 username = get_user_email(user)
12030 # Create export data
12031 export_data = {
12032 "exported_at": datetime.now().isoformat(),
12033 "exported_by": username,
12034 "export_type": "root",
12035 "version": "1.0",
12036 "root": {
12037 "uri": str(root.uri),
12038 "name": root.name,
12039 },
12040 }
12042 # Generate filename - sanitize URI for filename
12043 # Remove protocol and special characters
12044 safe_uri = uri.replace("://", "_").replace("/", "_").replace("\\", "_")
12045 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
12046 filename = f"root-export-{safe_uri}-{timestamp}.json"
12048 # Return as downloadable file
12049 content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode()
12050 return Response(
12051 content=content,
12052 media_type="application/json",
12053 headers={
12054 "Content-Disposition": f'attachment; filename="{filename}"',
12055 },
12056 )
12058 except RootServiceNotFoundError as e:
12059 LOGGER.error(f"Root not found for export by user {get_user_email(user)}: {str(e)}")
12060 raise HTTPException(status_code=404, detail=str(e))
12061 except Exception as e:
12062 LOGGER.error(f"Unexpected root export error for user {get_user_email(user)}: {str(e)}")
12063 raise HTTPException(status_code=500, detail=f"Root export failed: {str(e)}")
12066@admin_router.get("/roots/{uri:path}")
12067@require_permission("admin.system_config", allow_admin_bypass=False)
12068async def admin_get_root(uri: str, user=Depends(get_current_user_with_permissions)) -> dict:
12069 """Get a specific root by URI via the admin UI.
12071 This endpoint retrieves details for a specific root URI from the system.
12072 It requires authentication and logs the operation for audit purposes.
12074 Args:
12075 uri (str): The URI of the root to retrieve.
12076 user: Authenticated user dependency.
12078 Returns:
12079 dict: A dictionary containing the root information.
12081 Raises:
12082 HTTPException: If the root is not found.
12083 Exception: For any other unexpected errors.
12085 Examples:
12086 >>> callable(admin_get_root)
12087 True
12088 >>> admin_get_root.__name__
12089 'admin_get_root'
12090 """
12091 LOGGER.debug(f"User {get_user_email(user)} is retrieving root URI {uri}")
12092 try:
12093 root = await root_service.get_root_by_uri(uri)
12094 return root.model_dump(by_alias=True)
12095 except RootServiceNotFoundError as e:
12096 raise HTTPException(status_code=404, detail=str(e))
12097 except Exception as e:
12098 LOGGER.error(f"Error getting root {uri}: {e}")
12099 raise e
12102@admin_router.post("/roots")
12103@require_permission("admin.system_config", allow_admin_bypass=False)
12104async def admin_add_root(request: Request, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)) -> RedirectResponse:
12105 """Add a new root via the admin UI.
12107 Expects form fields:
12108 - uri
12109 - name (optional)
12111 Args:
12112 request: FastAPI request containing form data.
12113 user: Authenticated user.
12114 _db: Database session for permission checks.
12116 Returns:
12117 RedirectResponse: A redirect response to the admin dashboard.
12119 Examples:
12120 >>> callable(admin_add_root)
12121 True
12122 >>> admin_add_root.__name__
12123 'admin_add_root'
12124 """
12125 error_message = None
12126 user_email = get_user_email(user)
12127 LOGGER.debug(f"User {user_email} is adding a new root")
12129 form = await request.form()
12130 uri = str(form.get("uri", ""))
12131 name_value = form.get("name")
12132 name: str | None = None
12133 if isinstance(name_value, str) and name_value.strip():
12134 name = name_value.strip()
12136 try:
12137 if not uri:
12138 raise ValueError("URI is required")
12139 await root_service.add_root(str(uri), name)
12141 except RootServiceError as e:
12142 LOGGER.warning(f"Failed to add root for user {user_email}: {e}")
12143 error_message = "Failed to add root. Please check the URI format."
12144 except ValueError as e:
12145 LOGGER.warning(f"Invalid input from user {user_email}: {e}")
12146 error_message = "Invalid input. Please try again."
12147 except Exception as e:
12148 LOGGER.error(f"Error adding root: {e}")
12149 error_message = "Failed to add root. Please try again."
12151 root_path = request.scope.get("root_path", "")
12153 # Build redirect URL with error message if present
12154 if error_message:
12155 error_param = f"?error={urllib.parse.quote(error_message)}"
12156 return RedirectResponse(f"{root_path}/admin{error_param}#roots", status_code=303)
12158 return RedirectResponse(f"{root_path}/admin#roots", status_code=303)
12161@admin_router.post("/roots/{uri:path}/update")
12162@require_permission("admin.system_config", allow_admin_bypass=False)
12163async def admin_update_root(uri: str, request: Request, user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
12164 """Update a root via the admin UI.
12166 This endpoint updates an existing root URI in the system. It expects form
12167 fields for the new values and requires authentication.
12169 Expects form fields:
12170 - name (optional): New name for the root
12171 - is_inactive_checked: Whether the root should be marked as inactive
12173 Args:
12174 uri (str): The URI of the root to update.
12175 request (Request): FastAPI request object containing form data.
12176 user: Authenticated user dependency.
12178 Returns:
12179 RedirectResponse: A redirect response to the roots section of the admin
12180 dashboard with a status code of 303 (See Other).
12182 Raises:
12183 HTTPException: If the root is not found (404) or other errors occur.
12184 Exception: For any other unexpected errors.
12186 Examples:
12187 >>> callable(admin_update_root)
12188 True
12189 >>> admin_update_root.__name__
12190 'admin_update_root'
12191 """
12192 LOGGER.debug(f"User {get_user_email(user)} is updating root URI {uri}")
12194 try:
12195 form = await request.form()
12196 name_value = form.get("name")
12197 name: str | None = None
12199 if isinstance(name_value, str):
12200 name = name_value
12202 await root_service.update_root(uri, name)
12204 root_path = request.scope.get("root_path", "")
12205 is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
12207 if is_inactive_checked.lower() == "true":
12208 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#roots", status_code=303)
12209 return RedirectResponse(f"{root_path}/admin#roots", status_code=303)
12211 except RootServiceNotFoundError as e:
12212 raise HTTPException(status_code=404, detail=str(e))
12213 except Exception as e:
12214 LOGGER.error(f"Error updating root {uri}: {e}")
12215 raise e
12218@admin_router.post("/roots/{uri:path}/delete")
12219@require_permission("admin.system_config", allow_admin_bypass=False)
12220async def admin_delete_root(uri: str, request: Request, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)) -> RedirectResponse:
12221 """
12222 Delete a root via the admin UI.
12224 This endpoint removes a registered root URI from the system. The deletion is
12225 permanent and cannot be undone. It requires authentication and logs the
12226 operation for audit purposes.
12228 Args:
12229 uri (str): The URI of the root to delete.
12230 request (Request): FastAPI request object (not used directly but required by the route signature).
12231 user (str): Authenticated user dependency.
12232 _db: Database session for permission checks.
12234 Returns:
12235 RedirectResponse: A redirect response to the roots section of the admin
12236 dashboard with a status code of 303 (See Other).
12238 Examples:
12239 >>> callable(admin_delete_root)
12240 True
12241 >>> admin_delete_root.__name__
12242 'admin_delete_root'
12243 """
12244 LOGGER.debug(f"User {get_user_email(user)} is deleting root URI {uri}")
12245 await root_service.remove_root(uri)
12246 form = await request.form()
12247 root_path = request.scope.get("root_path", "")
12248 is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
12249 if is_inactive_checked.lower() == "true":
12250 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#roots", status_code=303)
12251 return RedirectResponse(f"{root_path}/admin#roots", status_code=303)
12254# Metrics
12255MetricsDict = Dict[str, Union[ToolMetrics, ResourceMetrics, ServerMetrics, PromptMetrics]]
12258# @admin_router.get("/metrics", response_model=MetricsDict)
12259# async def admin_get_metrics(
12260# db: Session = Depends(get_db),
12261# user=Depends(get_current_user_with_permissions),
12262# ) -> MetricsDict:
12263# """
12264# Retrieve aggregate metrics for all entity types via the admin UI.
12266# This endpoint collects and returns usage metrics for tools, resources, servers,
12267# and prompts. The metrics are retrieved by calling the aggregate_metrics method
12268# on each respective service, which compiles statistics about usage patterns,
12269# success rates, and other relevant metrics for administrative monitoring
12270# and analysis purposes.
12272# Args:
12273# db (Session): Database session dependency.
12274# user (str): Authenticated user dependency.
12276# Returns:
12277# MetricsDict: A dictionary containing the aggregated metrics for tools,
12278# resources, servers, and prompts. Each value is a Pydantic model instance
12279# specific to the entity type.
12280# """
12281# LOGGER.debug(f"User {get_user_email(user)} requested aggregate metrics")
12282# tool_metrics = await tool_service.aggregate_metrics(db)
12283# resource_metrics = await resource_service.aggregate_metrics(db)
12284# server_metrics = await server_service.aggregate_metrics(db)
12285# prompt_metrics = await prompt_service.aggregate_metrics(db)
12287# # Return actual Pydantic model instances
12288# return {
12289# "tools": tool_metrics,
12290# "resources": resource_metrics,
12291# "servers": server_metrics,
12292# "prompts": prompt_metrics,
12293# }
12296@admin_router.get("/metrics")
12297@require_permission("admin.system_config", allow_admin_bypass=False)
12298async def get_aggregated_metrics(
12299 db: Session = Depends(get_db),
12300 _user=Depends(get_current_user_with_permissions),
12301) -> Dict[str, Any]:
12302 """Retrieve aggregated metrics and top performers for all entity types.
12304 This endpoint collects usage metrics and top-performing entities for tools,
12305 resources, prompts, and servers by calling the respective service methods.
12306 The results are compiled into a dictionary for administrative monitoring.
12308 Args:
12309 db (Session): Database session dependency for querying metrics.
12311 Returns:
12312 Dict[str, Any]: A dictionary containing aggregated metrics and top performers
12313 for tools, resources, prompts, and servers. The structure includes:
12314 - 'tools': Metrics for tools.
12315 - 'resources': Metrics for resources.
12316 - 'prompts': Metrics for prompts.
12317 - 'servers': Metrics for servers.
12318 - 'topPerformers': A nested dictionary with all tools, resources, prompts,
12319 and servers with their metrics.
12320 """
12321 metrics = {
12322 "tools": await tool_service.aggregate_metrics(db),
12323 "resources": await resource_service.aggregate_metrics(db),
12324 "prompts": await prompt_service.aggregate_metrics(db),
12325 "servers": await server_service.aggregate_metrics(db),
12326 "topPerformers": {
12327 "tools": await tool_service.get_top_tools(db, limit=10),
12328 "resources": await resource_service.get_top_resources(db, limit=10),
12329 "prompts": await prompt_service.get_top_prompts(db, limit=10),
12330 "servers": await server_service.get_top_servers(db, limit=10),
12331 },
12332 }
12333 return metrics
12336@admin_router.get("/metrics/partial", response_class=HTMLResponse)
12337@require_permission("admin.system_config", allow_admin_bypass=False)
12338async def admin_metrics_partial_html(
12339 request: Request,
12340 entity_type: str = Query("tools", description="Entity type: tools, resources, prompts, or servers"),
12341 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
12342 per_page: int = Query(10, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
12343 db: Session = Depends(get_db),
12344 user=Depends(get_current_user_with_permissions),
12345):
12346 """
12347 Return HTML partial for paginated top performers (HTMX endpoint).
12349 Matches the /admin/tools/partial pattern for consistent pagination UX.
12351 Args:
12352 request: FastAPI request object
12353 entity_type: Entity type (tools, resources, prompts, servers)
12354 page: Page number (1-indexed)
12355 per_page: Items per page
12356 db: Database session
12357 user: Authenticated user
12359 Returns:
12360 HTMLResponse with paginated table and OOB pagination controls
12362 Raises:
12363 HTTPException: If entity_type is not one of the valid types
12364 """
12365 LOGGER.debug(f"User {get_user_email(user)} requested metrics partial (entity_type={entity_type}, page={page}, per_page={per_page})")
12367 # Validate entity type
12368 valid_types = ["tools", "resources", "prompts", "servers"]
12369 if entity_type not in valid_types:
12370 raise HTTPException(status_code=400, detail=f"Invalid entity_type. Must be one of: {', '.join(valid_types)}")
12372 # Constrain parameters
12373 page = max(1, page)
12374 per_page = max(1, min(per_page, 1000))
12376 # Get all items for this entity type
12377 if entity_type == "tools":
12378 all_items = await tool_service.get_top_tools(db, limit=None)
12379 elif entity_type == "resources":
12380 all_items = await resource_service.get_top_resources(db, limit=None)
12381 elif entity_type == "prompts":
12382 all_items = await prompt_service.get_top_prompts(db, limit=None)
12383 else: # servers
12384 all_items = await server_service.get_top_servers(db, limit=None)
12386 # Calculate pagination
12387 total_items = len(all_items)
12388 total_pages = math.ceil(total_items / per_page) if per_page > 0 else 0
12389 offset = (page - 1) * per_page
12390 paginated_items = all_items[offset : offset + per_page]
12392 # Convert to JSON-serializable format
12393 data = jsonable_encoder(paginated_items)
12395 # Build pagination metadata
12396 pagination = PaginationMeta(
12397 page=page,
12398 per_page=per_page,
12399 total_items=total_items,
12400 total_pages=total_pages,
12401 has_next=page < total_pages,
12402 has_prev=page > 1,
12403 )
12405 # Render template
12406 return request.app.state.templates.TemplateResponse(
12407 request,
12408 "metrics_top_performers_partial.html",
12409 {
12410 "request": request,
12411 "entity_type": entity_type,
12412 "data": data,
12413 "pagination": pagination.model_dump(),
12414 "root_path": request.scope.get("root_path", ""),
12415 },
12416 )
12419@admin_router.post("/metrics/reset", response_model=Dict[str, object])
12420@require_permission("admin.system_config", allow_admin_bypass=False)
12421async def admin_reset_metrics(db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, object]:
12422 """
12423 Reset all metrics for tools, resources, servers, and prompts.
12424 Each service must implement its own reset_metrics method.
12426 Args:
12427 db (Session): Database session dependency.
12428 user (str): Authenticated user dependency.
12430 Returns:
12431 Dict[str, object]: A dictionary containing a success message and status.
12433 Examples:
12434 >>> callable(admin_reset_metrics)
12435 True
12436 >>> admin_reset_metrics.__name__
12437 'admin_reset_metrics'
12438 """
12439 LOGGER.debug(f"User {get_user_email(user)} requested to reset all metrics")
12440 await tool_service.reset_metrics(db)
12441 await resource_service.reset_metrics(db)
12442 await server_service.reset_metrics(db)
12443 await prompt_service.reset_metrics(db)
12444 return {"message": "All metrics reset successfully", "success": True}
12447@admin_router.post("/gateways/test", response_model=GatewayTestResponse)
12448@require_permission("gateways.read", allow_admin_bypass=False)
12449async def admin_test_gateway(
12450 request: GatewayTestRequest, team_id: Optional[str] = Depends(_validated_team_id_param), user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)
12451) -> GatewayTestResponse:
12452 """
12453 Test a gateway by sending a request to its URL.
12454 This endpoint allows administrators to test the connectivity and response
12456 Args:
12457 request (GatewayTestRequest): The request object containing the gateway URL and request details.
12458 team_id (Optional[str]): Optional team ID for team-specific gateways.
12459 user (str): Authenticated user dependency.
12460 db (Session): Database session dependency.
12462 Returns:
12463 GatewayTestResponse: The response from the gateway, including status code, latency, and body
12465 Examples:
12466 >>> callable(admin_test_gateway)
12467 True
12468 >>> admin_test_gateway.__name__
12469 'admin_test_gateway'
12470 """
12471 full_url = str(request.base_url).rstrip("/") + "/" + request.path.lstrip("/")
12472 full_url = full_url.rstrip("/")
12473 LOGGER.debug(f"User {get_user_email(user)} testing server at {request.base_url}.")
12474 start_time: float = time.monotonic()
12475 headers = request.headers or {}
12477 # Attempt to find a registered gateway matching this URL and team
12478 try:
12479 gateway = gateway_service.get_first_gateway_by_url(db, str(request.base_url), team_id=team_id)
12480 except Exception:
12481 gateway = None
12483 try:
12484 user_email = get_user_email(user)
12485 if gateway and gateway.auth_type == "oauth" and gateway.oauth_config:
12486 grant_type = gateway.oauth_config.get("grant_type", "client_credentials")
12488 if grant_type == "authorization_code":
12489 # For Authorization Code flow, try to get stored tokens
12490 try:
12491 # First-Party
12492 from mcpgateway.services.token_storage_service import TokenStorageService # pylint: disable=import-outside-toplevel
12494 token_storage = TokenStorageService(db)
12496 # Get user-specific OAuth token
12497 if not user_email:
12498 latency_ms = int((time.monotonic() - start_time) * 1000)
12499 return GatewayTestResponse(
12500 status_code=401, latency_ms=latency_ms, body={"error": f"User authentication required for OAuth-protected gateway '{gateway.name}'. Please ensure you are authenticated."}
12501 )
12503 access_token: str = await token_storage.get_user_token(gateway.id, user_email)
12505 if access_token:
12506 headers["Authorization"] = f"Bearer {access_token}"
12507 else:
12508 latency_ms = int((time.monotonic() - start_time) * 1000)
12509 return GatewayTestResponse(
12510 status_code=401, latency_ms=latency_ms, body={"error": f"Please authorize {gateway.name} first. Visit /oauth/authorize/{gateway.id} to complete OAuth flow."}
12511 )
12512 except Exception as e:
12513 LOGGER.error(f"Failed to obtain stored OAuth token for gateway {gateway.name}: {e}")
12514 latency_ms = int((time.monotonic() - start_time) * 1000)
12515 return GatewayTestResponse(status_code=500, latency_ms=latency_ms, body={"error": f"OAuth token retrieval failed for gateway: {str(e)}"})
12516 else:
12517 # For Client Credentials flow, get token directly
12518 try:
12519 oauth_manager = OAuthManager(request_timeout=int(os.getenv("OAUTH_REQUEST_TIMEOUT", "30")), max_retries=int(os.getenv("OAUTH_MAX_RETRIES", "3")))
12520 access_token: str = await oauth_manager.get_access_token(gateway.oauth_config)
12521 headers["Authorization"] = f"Bearer {access_token}"
12522 except Exception as e:
12523 LOGGER.error(f"Failed to obtain OAuth access token for gateway {gateway.name}: {e}")
12524 response_body = {"error": f"OAuth token retrieval failed for gateway: {str(e)}"}
12525 else:
12526 headers: dict = decode_auth(gateway.auth_value if gateway else None)
12528 # Prepare request based on content type
12529 content_type = getattr(request, "content_type", "application/json")
12530 request_kwargs = {"method": request.method.upper(), "url": full_url, "headers": headers}
12532 if request.body is not None:
12533 if content_type == "application/x-www-form-urlencoded":
12534 # Set proper content type header and use data parameter for form encoding
12535 headers["Content-Type"] = "application/x-www-form-urlencoded"
12536 request_kwargs["data"] = request.body
12537 else:
12538 # Default to JSON
12539 headers["Content-Type"] = "application/json"
12540 request_kwargs["json"] = request.body
12542 async with ResilientHttpClient(client_args={"timeout": settings.federation_timeout, "verify": not settings.skip_ssl_verify}) as client:
12543 response: httpx.Response = await client.request(**request_kwargs)
12544 latency_ms = int((time.monotonic() - start_time) * 1000)
12545 try:
12546 response_body: Union[Dict[str, Any], str] = response.json()
12547 except ValueError:
12548 response_body = {"details": response.text}
12550 # Structured logging: Log successful gateway test
12551 structured_logger = get_structured_logger("gateway_service")
12552 structured_logger.log(
12553 level="INFO",
12554 message=f"Gateway test completed: {request.base_url}",
12555 event_type="gateway_tested",
12556 component="gateway_service",
12557 user_email=get_user_email(user),
12558 team_id=team_id,
12559 resource_type="gateway",
12560 resource_id=gateway.id if gateway else None,
12561 custom_fields={
12562 "gateway_name": gateway.name if gateway else None,
12563 "gateway_url": str(request.base_url),
12564 "test_method": request.method,
12565 "test_path": request.path,
12566 "status_code": response.status_code,
12567 "latency_ms": latency_ms,
12568 },
12569 )
12571 return GatewayTestResponse(status_code=response.status_code, latency_ms=latency_ms, body=response_body)
12573 except httpx.RequestError as e:
12574 LOGGER.warning(f"Gateway test failed: {e}")
12575 latency_ms = int((time.monotonic() - start_time) * 1000)
12577 # Structured logging: Log failed gateway test
12578 structured_logger = get_structured_logger("gateway_service")
12579 structured_logger.log(
12580 level="ERROR",
12581 message=f"Gateway test failed: {request.base_url}",
12582 event_type="gateway_test_failed",
12583 component="gateway_service",
12584 user_email=get_user_email(user),
12585 team_id=team_id,
12586 resource_type="gateway",
12587 resource_id=gateway.id if gateway else None,
12588 error=e,
12589 custom_fields={
12590 "gateway_name": gateway.name if gateway else None,
12591 "gateway_url": str(request.base_url),
12592 "test_method": request.method,
12593 "test_path": request.path,
12594 "latency_ms": latency_ms,
12595 },
12596 )
12598 return GatewayTestResponse(status_code=502, latency_ms=latency_ms, body={"error": "Request failed", "details": str(e)})
12601# Event Streaming via SSE to the Admin UI
12602@admin_router.get("/events")
12603@require_permission("admin.events", allow_admin_bypass=False)
12604async def admin_events(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
12605 """
12606 Stream admin events from all services via SSE (Server-Sent Events).
12608 This endpoint establishes a persistent connection to stream real-time updates
12609 from the gateway service and tool service to the frontend. It aggregates
12610 multiple event streams into a single asyncio queue for unified delivery.
12612 Args:
12613 request (Request): The FastAPI request object, used to detect client disconnection.
12614 _user (Any): Authenticated user dependency (ensures admin permissions).
12615 _db: Database session for permission checks.
12617 Returns:
12618 StreamingResponse: An async generator yielding SSE-formatted strings
12619 (media_type="text/event-stream").
12621 Examples:
12622 >>> # Test function exists and has correct name
12623 >>> from mcpgateway.admin import admin_events
12624 >>> admin_events.__name__
12625 'admin_events'
12626 >>> # Test it's a coroutine function
12627 >>> import inspect
12628 >>> inspect.iscoroutinefunction(admin_events)
12629 True
12630 """
12631 # Create a shared queue to aggregate events from all services
12632 event_queue = asyncio.Queue()
12633 heartbeat_interval = 15.0
12635 # Define a generic producer that feeds a specific stream into the queue
12636 async def stream_to_queue(generator, source_name: str):
12637 """Consume events from an async generator and forward them to a queue.
12639 This coroutine iterates over an asynchronous generator and enqueues each
12640 yielded event into a global or external `event_queue`. It gracefully
12641 handles task cancellation and logs unexpected exceptions.
12643 Args:
12644 generator (AsyncGenerator): An asynchronous generator that yields events.
12645 source_name (str): A human-readable label for the event source, used
12646 for logging error messages.
12648 Raises:
12649 asyncio.CancelledError: If the task is cancelled externally.
12650 Exception: Any unexpected exception raised while iterating over the
12651 generator will be caught, logged, and suppressed.
12653 Doctest:
12654 >>> import asyncio
12655 >>> class FakeQueue:
12656 ... def __init__(self):
12657 ... self.items = []
12658 ... async def put(self, item):
12659 ... self.items.append(item)
12660 ...
12661 >>> async def fake_gen():
12662 ... yield 1
12663 ... yield 2
12664 ... yield 3
12665 ...
12666 >>> event_queue = FakeQueue() # monkey-patch the global name
12667 >>> async def run_test():
12668 ... await stream_to_queue(fake_gen(), "test_source")
12669 ... return event_queue.items
12670 ...
12671 >>> asyncio.run(run_test())
12672 [1, 2, 3]
12674 """
12675 try:
12676 async for event in generator:
12677 await event_queue.put(event)
12678 except Exception as e:
12679 LOGGER.error(f"Error in {source_name} event subscription: {e}")
12681 async def event_generator():
12682 """
12683 Asynchronous Server-Sent Events (SSE) generator.
12685 This coroutine listens to multiple background event streams (e.g., from
12686 gateway and tool services), funnels their events into a shared queue, and
12687 yields them to the client in proper SSE format.
12689 The function:
12690 - Spawns background tasks to consume events from subscribed services.
12691 - Monitors the client connection for disconnection.
12692 - Yields SSE-formatted messages as they arrive.
12693 - Cleans up subscription tasks on exit.
12695 The SSE format emitted:
12696 event: <event_type>
12697 data: <json-encoded data>
12699 Yields:
12700 AsyncGenerator[str, None]: A generator yielding SSE-formatted strings.
12702 Raises:
12703 asyncio.CancelledError: If the SSE stream or background tasks are cancelled.
12704 Exception: Any unexpected exception in the main loop is logged but not re-raised.
12706 Notes:
12707 This function expects the following names to exist in the outer scope:
12708 - `request`: A FastAPI/Starlette Request object.
12709 - `event_queue`: An asyncio.Queue instance where events are dispatched.
12710 - `gateway_service` and `tool_service`: Services exposing async subscribe_events().
12711 - `stream_to_queue`: Coroutine to pipe service streams into the queue.
12712 - `LOGGER`: Logger instance.
12714 Example:
12715 Basic doctest demonstrating SSE formatting from mock data:
12717 >>> import orjson, asyncio
12718 >>> class DummyRequest:
12719 ... async def is_disconnected(self):
12720 ... return False
12721 >>> async def dummy_gen():
12722 ... # Simulate an event queue and minimal environment
12723 ... global request, event_queue
12724 ... request = DummyRequest()
12725 ... event_queue = asyncio.Queue()
12726 ... # Minimal stubs to satisfy references
12727 ... class DummyService:
12728 ... async def subscribe_events(self):
12729 ... async def gen():
12730 ... yield {"type": "test", "data": {"a": 1}}
12731 ... return gen()
12732 ... global gateway_service, tool_service, stream_to_queue, LOGGER
12733 ... gateway_service = tool_service = DummyService()
12734 ... async def stream_to_queue(gen, tag):
12735 ... async for e in gen:
12736 ... await event_queue.put(e)
12737 ... class DummyLogger:
12738 ... def debug(self, *args, **kwargs): pass
12739 ... def error(self, *args, **kwargs): pass
12740 ... LOGGER = DummyLogger()
12741 ...
12742 ... agen = event_generator()
12743 ... # Startup requires allowing tasks to enqueue
12744 ... async def get_one():
12745 ... async for msg in agen:
12746 ... return msg
12747 ... return (await get_one()).startswith("event: test")
12748 >>> asyncio.run(dummy_gen())
12749 True
12750 """
12751 # Create background tasks for each service subscription
12752 # This allows them to run concurrently
12753 tasks = [asyncio.create_task(stream_to_queue(gateway_service.subscribe_events(), "gateway")), asyncio.create_task(stream_to_queue(tool_service.subscribe_events(), "tool"))]
12755 try:
12756 while True:
12757 # Check for client disconnection
12758 if await request.is_disconnected():
12759 LOGGER.debug("SSE Client disconnected")
12760 break
12762 # Wait for the next event from EITHER service
12763 # We use asyncio.wait_for to allow checking request.is_disconnected periodically
12764 # or simply rely on queue.get() which is efficient.
12765 try:
12766 # Wait for an event or send a keepalive to avoid idle timeouts
12767 event = await asyncio.wait_for(event_queue.get(), timeout=heartbeat_interval)
12769 # SSE format
12770 event_type = event.get("type", "message")
12771 event_data = orjson.dumps(event.get("data", {})).decode()
12773 yield f"event: {event_type}\ndata: {event_data}\n\n"
12775 # Mark task as done in queue (good practice)
12776 event_queue.task_done()
12777 except asyncio.TimeoutError:
12778 yield ": keepalive\n\n"
12780 except asyncio.CancelledError:
12781 LOGGER.debug("SSE Event generator task cancelled")
12782 raise
12784 except asyncio.CancelledError:
12785 LOGGER.debug("SSE Stream cancelled")
12786 raise
12787 except Exception as e:
12788 LOGGER.error(f"SSE Stream error: {e}")
12789 finally:
12790 # Cleanup: Cancel all background subscription tasks
12791 # This is crucial to close Redis connections/listeners in the EventService
12792 for task in tasks:
12793 task.cancel()
12795 # Wait for tasks to clean up
12796 await asyncio.gather(*tasks, return_exceptions=True)
12797 LOGGER.debug("Background event subscription tasks cleaned up")
12799 return StreamingResponse(
12800 event_generator(),
12801 media_type="text/event-stream",
12802 headers={
12803 "Cache-Control": "no-cache",
12804 "Connection": "keep-alive",
12805 "X-Accel-Buffering": "no",
12806 },
12807 )
12810####################
12811# Admin Tag Routes #
12812####################
12815@admin_router.get("/tags", response_model=PaginatedResponse)
12816@require_permission("tags.read", allow_admin_bypass=False)
12817async def admin_list_tags(
12818 entity_types: Optional[str] = None,
12819 include_entities: bool = False,
12820 db: Session = Depends(get_db),
12821 user=Depends(get_current_user_with_permissions),
12822) -> List[Dict[str, Any]]:
12823 """
12824 List all unique tags with statistics for the admin UI.
12826 Args:
12827 entity_types: Comma-separated list of entity types to filter by
12828 (e.g., "tools,resources,prompts,servers,gateways").
12829 If not provided, returns tags from all entity types.
12830 include_entities: Whether to include the list of entities that have each tag
12831 db: Database session
12832 user: Authenticated user
12834 Returns:
12835 List of tag information with statistics
12837 Raises:
12838 HTTPException: If tag retrieval fails
12840 Examples:
12841 >>> # Test function exists and has correct name
12842 >>> from mcpgateway.admin import admin_list_tags
12843 >>> admin_list_tags.__name__
12844 'admin_list_tags'
12845 >>> # Test it's a coroutine function
12846 >>> import inspect
12847 >>> inspect.iscoroutinefunction(admin_list_tags)
12848 True
12849 """
12850 tag_service = TagService()
12852 # Parse entity types parameter if provided
12853 entity_types_list = None
12854 if entity_types:
12855 entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()]
12857 LOGGER.debug(f"Admin user {user} is retrieving tags for entity types: {entity_types_list}, include_entities: {include_entities}")
12859 try:
12860 tags = await tag_service.get_all_tags(db, entity_types=entity_types_list, include_entities=include_entities)
12862 # Convert to list of dicts for admin UI
12863 result: List[Dict[str, Any]] = []
12864 for tag in tags:
12865 tag_dict: Dict[str, Any] = {
12866 "name": tag.name,
12867 "tools": tag.stats.tools,
12868 "resources": tag.stats.resources,
12869 "prompts": tag.stats.prompts,
12870 "servers": tag.stats.servers,
12871 "gateways": tag.stats.gateways,
12872 "total": tag.stats.total,
12873 }
12875 # Include entities if requested
12876 if include_entities and tag.entities:
12877 tag_dict["entities"] = [
12878 {
12879 "id": entity.id,
12880 "name": entity.name,
12881 "type": entity.type,
12882 "description": entity.description,
12883 }
12884 for entity in tag.entities
12885 ]
12887 result.append(tag_dict)
12889 return result
12890 except Exception as e:
12891 LOGGER.error(f"Failed to retrieve tags for admin: {str(e)}")
12892 raise HTTPException(status_code=500, detail=f"Failed to retrieve tags: {str(e)}")
12895async def _read_request_json(request: Request) -> Any:
12896 """Read JSON payload using orjson, falling back to request.json for mocks.
12898 Args:
12899 request: Incoming FastAPI request to read JSON from.
12901 Returns:
12902 Parsed JSON payload (dict/list/etc.).
12903 """
12904 body = await request.body()
12905 if isinstance(body, (bytes, bytearray, memoryview)):
12906 if body:
12907 return orjson.loads(body)
12908 elif isinstance(body, str) and body:
12909 return orjson.loads(body)
12910 return await request.json()
12913@admin_router.post("/tools/import/")
12914@admin_router.post("/tools/import")
12915@require_permission("tools.create", allow_admin_bypass=False)
12916@rate_limit(requests_per_minute=settings.mcpgateway_bulk_import_rate_limit)
12917async def admin_import_tools(
12918 request: Request,
12919 db: Session = Depends(get_db),
12920 user=Depends(get_current_user_with_permissions),
12921) -> JSONResponse:
12922 """Bulk import multiple tools in a single request.
12924 Accepts a JSON array of tool definitions and registers them individually.
12925 Provides per-item validation and error reporting without failing the entire batch.
12927 Args:
12928 request: FastAPI Request containing the tools data
12929 db: Database session
12930 user: Authenticated username
12932 Returns:
12933 JSONResponse with success status, counts, and details of created/failed tools
12935 Raises:
12936 HTTPException: For authentication or rate limiting failures
12937 """
12938 # Check if bulk import is enabled
12939 if not settings.mcpgateway_bulk_import_enabled:
12940 LOGGER.warning("Bulk import attempted but feature is disabled")
12941 raise HTTPException(status_code=403, detail="Bulk import feature is disabled. Enable MCPGATEWAY_BULK_IMPORT_ENABLED to use this endpoint.")
12943 LOGGER.debug("bulk tool import: user=%s", user)
12944 try:
12945 # ---------- robust payload parsing ----------
12946 ctype = (request.headers.get("content-type") or "").lower()
12947 if "application/json" in ctype:
12948 try:
12949 payload = await _read_request_json(request)
12950 except Exception as ex:
12951 LOGGER.exception("Invalid JSON body")
12952 return ORJSONResponse({"success": False, "message": f"Invalid JSON: {ex}"}, status_code=422)
12953 else:
12954 try:
12955 form = await request.form()
12956 except Exception as ex:
12957 LOGGER.exception("Invalid form body")
12958 return ORJSONResponse({"success": False, "message": f"Invalid form data: {ex}"}, status_code=422)
12959 # Check for file upload first
12960 if "tools_file" in form:
12961 file = form["tools_file"]
12962 if isinstance(file, StarletteUploadFile):
12963 content = await file.read()
12964 try:
12965 payload = orjson.loads(content.decode("utf-8"))
12966 except (orjson.JSONDecodeError, UnicodeDecodeError) as ex:
12967 LOGGER.exception("Invalid JSON file")
12968 return ORJSONResponse({"success": False, "message": f"Invalid JSON file: {ex}"}, status_code=422)
12969 else:
12970 return ORJSONResponse({"success": False, "message": "Invalid file upload"}, status_code=422)
12971 else:
12972 # Check for JSON in form fields
12973 raw_val = form.get("tools") or form.get("tools_json") or form.get("json") or form.get("payload")
12974 raw = raw_val if isinstance(raw_val, str) else None
12975 if not raw:
12976 return ORJSONResponse({"success": False, "message": "Missing tools/tools_json/json/payload form field."}, status_code=422)
12977 try:
12978 payload = orjson.loads(raw)
12979 except Exception as ex:
12980 LOGGER.exception("Invalid JSON in form field")
12981 return ORJSONResponse({"success": False, "message": f"Invalid JSON: {ex}"}, status_code=422)
12983 if not isinstance(payload, list):
12984 return ORJSONResponse({"success": False, "message": "Payload must be a JSON array of tools."}, status_code=422)
12986 max_batch = settings.mcpgateway_bulk_import_max_tools
12987 if len(payload) > max_batch:
12988 return ORJSONResponse({"success": False, "message": f"Too many tools ({len(payload)}). Max {max_batch}."}, status_code=413)
12990 created, errors = [], []
12992 # ---------- import loop ----------
12993 # Generate import batch ID for this bulk operation
12994 import_batch_id = str(uuid.uuid4())
12996 # Extract base metadata for bulk import
12997 base_metadata = MetadataCapture.extract_creation_metadata(request, user, import_batch_id=import_batch_id)
12998 for i, item in enumerate(payload):
12999 name = (item or {}).get("name")
13000 try:
13001 tool = ToolCreate(**item) # pydantic validation
13002 await tool_service.register_tool(
13003 db,
13004 tool,
13005 created_by=base_metadata["created_by"],
13006 created_from_ip=base_metadata["created_from_ip"],
13007 created_via="import", # Override to show this is bulk import
13008 created_user_agent=base_metadata["created_user_agent"],
13009 import_batch_id=import_batch_id,
13010 federation_source=base_metadata["federation_source"],
13011 )
13012 created.append({"index": i, "name": name})
13013 except IntegrityError as ex:
13014 # The formatter can itself throw; guard it.
13015 try:
13016 formatted = ErrorFormatter.format_database_error(ex)
13017 except Exception:
13018 formatted = {"message": str(ex)}
13019 errors.append({"index": i, "name": name, "error": formatted})
13020 except (ValidationError, CoreValidationError) as ex:
13021 # Ditto: guard the formatter
13022 try:
13023 formatted = ErrorFormatter.format_validation_error(ex)
13024 except Exception:
13025 formatted = {"message": str(ex)}
13026 errors.append({"index": i, "name": name, "error": formatted})
13027 except ToolError as ex:
13028 errors.append({"index": i, "name": name, "error": {"message": str(ex)}})
13029 except Exception as ex:
13030 LOGGER.exception("Unexpected error importing tool %r at index %d", name, i)
13031 errors.append({"index": i, "name": name, "error": {"message": str(ex)}})
13033 # Format response to match both frontend and test expectations
13034 response_data = {
13035 "success": len(errors) == 0,
13036 # New format for frontend
13037 "imported": len(created),
13038 "failed": len(errors),
13039 "total": len(payload),
13040 # Original format for tests
13041 "created_count": len(created),
13042 "failed_count": len(errors),
13043 "created": created,
13044 "errors": errors,
13045 # Detailed format for frontend
13046 "details": {
13047 "success": [item["name"] for item in created if item.get("name")],
13048 "failed": [{"name": item["name"], "error": item["error"].get("message", str(item["error"]))} for item in errors],
13049 },
13050 }
13052 rd = typing_cast(Dict[str, Any], response_data)
13053 if len(errors) == 0:
13054 rd["message"] = f"Successfully imported all {len(created)} tools"
13055 else:
13056 rd["message"] = f"Imported {len(created)} of {len(payload)} tools. {len(errors)} failed."
13058 return ORJSONResponse(
13059 response_data,
13060 status_code=200, # Always return 200, success field indicates if all succeeded
13061 )
13063 except HTTPException:
13064 # let FastAPI semantics (e.g., auth) pass through
13065 raise
13066 except Exception as ex:
13067 # absolute catch-all: report instead of crashing
13068 LOGGER.exception("Fatal error in admin_import_tools")
13069 return ORJSONResponse({"success": False, "message": str(ex)}, status_code=500)
13072####################
13073# Log Endpoints
13074####################
13077@admin_router.get("/logs")
13078@require_permission("admin.system_config", allow_admin_bypass=False)
13079async def admin_get_logs(
13080 entity_type: Optional[str] = None,
13081 entity_id: Optional[str] = None,
13082 level: Optional[str] = None,
13083 start_time: Optional[str] = None,
13084 end_time: Optional[str] = None,
13085 request_id: Optional[str] = None,
13086 search: Optional[str] = None,
13087 limit: int = 100,
13088 offset: int = 0,
13089 order: str = "desc",
13090 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
13091 _db: Session = Depends(get_db),
13092) -> Dict[str, Any]:
13093 """Get filtered log entries from the in-memory buffer.
13095 Args:
13096 entity_type: Filter by entity type (tool, resource, server, gateway)
13097 entity_id: Filter by entity ID
13098 level: Minimum log level (debug, info, warning, error, critical)
13099 start_time: ISO format start time
13100 end_time: ISO format end time
13101 request_id: Filter by request ID
13102 search: Search in message text
13103 limit: Maximum number of results (default 100, max 1000)
13104 offset: Number of results to skip
13105 order: Sort order (asc or desc)
13106 user: Authenticated user
13107 _db: Database session for permission checks.
13109 Returns:
13110 Dictionary with logs and metadata
13112 Raises:
13113 HTTPException: If validation fails or service unavailable
13114 """
13115 # Get log storage from logging service
13116 storage = typing_cast(Any, logging_service).get_storage()
13117 if not storage:
13118 return {"logs": [], "total": 0, "stats": {}}
13120 # Parse timestamps if provided
13121 start_dt = None
13122 end_dt = None
13123 if start_time:
13124 try:
13125 start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
13126 except ValueError:
13127 raise HTTPException(400, f"Invalid start_time format: {start_time}")
13129 if end_time:
13130 try:
13131 end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
13132 except ValueError:
13133 raise HTTPException(400, f"Invalid end_time format: {end_time}")
13135 # Parse log level
13136 log_level = None
13137 if level:
13138 try:
13139 log_level = LogLevel(level.lower())
13140 except ValueError:
13141 raise HTTPException(400, f"Invalid log level: {level}")
13143 # Limit max results
13144 limit = min(limit, 1000)
13146 # Get filtered logs
13147 logs = await storage.get_logs(
13148 entity_type=entity_type,
13149 entity_id=entity_id,
13150 level=log_level,
13151 start_time=start_dt,
13152 end_time=end_dt,
13153 request_id=request_id,
13154 search=search,
13155 limit=limit,
13156 offset=offset,
13157 order=order,
13158 )
13160 # Get statistics
13161 stats = storage.get_stats()
13163 return {
13164 "logs": logs,
13165 "total": stats.get("total_logs", 0),
13166 "stats": stats,
13167 }
13170@admin_router.get("/logs/stream")
13171@require_permission("admin.system_config", allow_admin_bypass=False)
13172async def admin_stream_logs(
13173 request: Request,
13174 entity_type: Optional[str] = None,
13175 entity_id: Optional[str] = None,
13176 level: Optional[str] = None,
13177 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
13178 _db: Session = Depends(get_db),
13179):
13180 """Stream real-time log updates via Server-Sent Events.
13182 Args:
13183 request: FastAPI request object
13184 entity_type: Filter by entity type
13185 entity_id: Filter by entity ID
13186 level: Minimum log level
13187 user: Authenticated user
13188 _db: Database session for permission checks.
13190 Returns:
13191 SSE response with real-time log updates
13193 Raises:
13194 HTTPException: If log level is invalid or service unavailable
13195 """
13196 # Get log storage from logging service
13197 storage = typing_cast(Any, logging_service).get_storage()
13198 if not storage:
13199 raise HTTPException(503, "Log storage not available")
13201 # Parse log level filter
13202 min_level = None
13203 if level:
13204 try:
13205 min_level = LogLevel(level.lower())
13206 except ValueError:
13207 raise HTTPException(400, f"Invalid log level: {level}")
13209 async def generate():
13210 """Generate SSE events for log streaming.
13212 Yields:
13213 Formatted SSE events containing log data
13214 """
13215 try:
13216 async for event in storage.subscribe():
13217 # Check if client disconnected
13218 if await request.is_disconnected():
13219 break
13221 # Apply filters
13222 log_data = event.get("data", {})
13224 # Entity type filter
13225 if entity_type and log_data.get("entity_type") != entity_type:
13226 continue
13228 # Entity ID filter
13229 if entity_id and log_data.get("entity_id") != entity_id:
13230 continue
13232 # Level filter
13233 if min_level:
13234 log_level = log_data.get("level")
13235 if log_level:
13236 try:
13237 if not storage._meets_level_threshold(LogLevel(log_level), min_level): # pylint: disable=protected-access
13238 continue
13239 except ValueError:
13240 continue
13242 # Send SSE event
13243 yield f"data: {orjson.dumps(event).decode()}\n\n"
13245 except Exception as e:
13246 LOGGER.error(f"Error in log streaming: {e}")
13247 yield f"event: error\ndata: {orjson.dumps({'error': str(e)}).decode()}\n\n"
13249 return StreamingResponse(
13250 generate(),
13251 media_type="text/event-stream",
13252 headers={
13253 "Cache-Control": "no-cache",
13254 "X-Accel-Buffering": "no", # Disable Nginx buffering
13255 },
13256 )
13259@admin_router.get("/logs/file")
13260@require_permission("admin.system_config", allow_admin_bypass=False)
13261async def admin_get_log_file(
13262 filename: Optional[str] = None,
13263 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
13264 _db: Session = Depends(get_db),
13265):
13266 """Download log file.
13268 Args:
13269 filename: Specific log file to download (optional)
13270 user: Authenticated user
13271 _db: Database session for permission checks.
13273 Returns:
13274 File download response or list of available files
13276 Raises:
13277 HTTPException: If file doesn't exist or access denied
13278 """
13279 # Check if file logging is enabled
13280 if not settings.log_to_file or not settings.log_file:
13281 raise HTTPException(404, "File logging is not enabled")
13283 # Determine log directory
13284 log_dir = Path(settings.log_folder) if settings.log_folder else Path(".")
13286 if filename:
13287 # Download specific file
13288 file_path = log_dir / filename
13290 # Security: Ensure file is within log directory
13291 try:
13292 file_path = file_path.resolve()
13293 log_dir_resolved = log_dir.resolve()
13294 if not str(file_path).startswith(str(log_dir_resolved)):
13295 raise HTTPException(403, "Access denied")
13296 except Exception:
13297 raise HTTPException(400, "Invalid file path")
13299 # Check if file exists
13300 if not file_path.exists() or not file_path.is_file():
13301 raise HTTPException(404, f"Log file not found: {filename}")
13303 # Check if it's a log file
13304 if not (file_path.suffix in [".log", ".jsonl", ".json"] or file_path.stem.startswith(Path(settings.log_file).stem)):
13305 raise HTTPException(403, "Not a log file")
13307 # Return file for download using FileResponse (streams asynchronously)
13308 # Pre-stat the file to catch issues early and provide Content-Length
13309 try:
13310 file_stat = file_path.stat()
13311 LOGGER.info(f"Serving log file download: {file_path.name} ({file_stat.st_size} bytes)")
13312 return FileResponse(
13313 path=file_path,
13314 media_type="application/octet-stream",
13315 filename=file_path.name,
13316 stat_result=file_stat,
13317 )
13318 except FileNotFoundError:
13319 LOGGER.error(f"Log file disappeared before streaming: {filename}")
13320 raise HTTPException(404, f"Log file not found: {filename}")
13321 except Exception as e:
13322 LOGGER.error(f"Error preparing file for download: {e}")
13323 raise HTTPException(500, f"Error reading file for download: {e}")
13325 # List available log files
13326 log_files = []
13328 try:
13329 # Main log file
13330 main_log = log_dir / settings.log_file
13331 if main_log.exists():
13332 stat = main_log.stat()
13333 log_files.append(
13334 {
13335 "name": main_log.name,
13336 "size": stat.st_size,
13337 "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
13338 "type": "main",
13339 }
13340 )
13342 # Rotated log files
13343 if settings.log_rotation_enabled:
13344 pattern = f"{Path(settings.log_file).stem}.*"
13345 for file in log_dir.glob(pattern):
13346 if file.is_file() and file.name != main_log.name: # Exclude main log file
13347 stat = file.stat()
13348 log_files.append(
13349 {
13350 "name": file.name,
13351 "size": stat.st_size,
13352 "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
13353 "type": "rotated",
13354 }
13355 )
13357 # Storage log file (JSON lines)
13358 storage_log = log_dir / f"{Path(settings.log_file).stem}_storage.jsonl"
13359 if storage_log.exists():
13360 stat = storage_log.stat()
13361 log_files.append(
13362 {
13363 "name": storage_log.name,
13364 "size": stat.st_size,
13365 "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
13366 "type": "storage",
13367 }
13368 )
13370 # Sort by modified time (newest first)
13371 log_files.sort(key=lambda x: x["modified"], reverse=True)
13373 except Exception as e:
13374 LOGGER.error(f"Error listing log files: {e}")
13375 raise HTTPException(500, f"Error listing log files: {e}")
13377 return {
13378 "log_directory": str(log_dir),
13379 "files": log_files,
13380 "total": len(log_files),
13381 }
13384@admin_router.get("/logs/export")
13385@require_permission("admin.system_config", allow_admin_bypass=False)
13386async def admin_export_logs(
13387 export_format: str = Query("json", alias="format"),
13388 entity_type: Optional[str] = None,
13389 entity_id: Optional[str] = None,
13390 level: Optional[str] = None,
13391 start_time: Optional[str] = None,
13392 end_time: Optional[str] = None,
13393 request_id: Optional[str] = None,
13394 search: Optional[str] = None,
13395 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
13396 _db: Session = Depends(get_db),
13397):
13398 """Export filtered logs in JSON or CSV format.
13400 Args:
13401 export_format: Export format (json or csv)
13402 entity_type: Filter by entity type
13403 entity_id: Filter by entity ID
13404 level: Minimum log level
13405 start_time: ISO format start time
13406 end_time: ISO format end time
13407 request_id: Filter by request ID
13408 search: Search in message text
13409 user: Authenticated user
13410 _db: Database session for permission checks.
13412 Returns:
13413 File download response with exported logs
13415 Raises:
13416 HTTPException: If validation fails or export format invalid
13417 """
13418 # Standard
13419 # Validate format
13420 if export_format not in ["json", "csv"]:
13421 raise HTTPException(400, f"Invalid format: {export_format}. Use 'json' or 'csv'")
13423 # Get log storage from logging service
13424 storage = typing_cast(Any, logging_service).get_storage()
13425 if not storage:
13426 raise HTTPException(503, "Log storage not available")
13428 # Parse timestamps if provided
13429 start_dt = None
13430 end_dt = None
13431 if start_time:
13432 try:
13433 start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
13434 except ValueError:
13435 raise HTTPException(400, f"Invalid start_time format: {start_time}")
13437 if end_time:
13438 try:
13439 end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
13440 except ValueError:
13441 raise HTTPException(400, f"Invalid end_time format: {end_time}")
13443 # Parse log level
13444 log_level = None
13445 if level:
13446 try:
13447 log_level = LogLevel(level.lower())
13448 except ValueError:
13449 raise HTTPException(400, f"Invalid log level: {level}")
13451 # Get all matching logs (no pagination for export)
13452 logs = await storage.get_logs(
13453 entity_type=entity_type,
13454 entity_id=entity_id,
13455 level=log_level,
13456 start_time=start_dt,
13457 end_time=end_dt,
13458 request_id=request_id,
13459 search=search,
13460 limit=10000, # Reasonable max for export
13461 offset=0,
13462 order="desc",
13463 )
13465 # Generate filename
13466 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
13467 filename = f"logs_export_{timestamp}.{export_format}"
13469 if export_format == "json":
13470 # Export as JSON
13471 content = orjson.dumps(logs, default=str, option=orjson.OPT_INDENT_2).decode()
13472 return Response(
13473 content=content,
13474 media_type="application/json",
13475 headers={
13476 "Content-Disposition": f'attachment; filename="{filename}"',
13477 },
13478 )
13480 # CSV format
13481 # Create CSV content
13482 output = io.StringIO()
13484 if logs:
13485 # Use first log to determine columns
13486 fieldnames = [
13487 "timestamp",
13488 "level",
13489 "entity_type",
13490 "entity_id",
13491 "entity_name",
13492 "message",
13493 "logger",
13494 "request_id",
13495 ]
13497 writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
13498 writer.writeheader()
13500 for log in logs:
13501 # Flatten the log entry for CSV
13502 row = {k: log.get(k, "") for k in fieldnames}
13503 writer.writerow(row)
13505 content = output.getvalue()
13507 return Response(
13508 content=content,
13509 media_type="text/csv",
13510 headers={
13511 "Content-Disposition": f'attachment; filename="{filename}"',
13512 },
13513 )
13516@admin_router.get("/export/configuration")
13517@require_permission("admin.system_config", allow_admin_bypass=False)
13518async def admin_export_configuration(
13519 request: Request, # pylint: disable=unused-argument
13520 types: Optional[str] = None,
13521 exclude_types: Optional[str] = None,
13522 tags: Optional[str] = None,
13523 include_inactive: bool = False,
13524 include_dependencies: bool = True,
13525 db: Session = Depends(get_db),
13526 user=Depends(get_current_user_with_permissions),
13527):
13528 """
13529 Export gateway configuration via Admin UI.
13531 Args:
13532 request: FastAPI request object for extracting root path
13533 types: Comma-separated entity types to include
13534 exclude_types: Comma-separated entity types to exclude
13535 tags: Comma-separated tags to filter by
13536 include_inactive: Include inactive entities
13537 include_dependencies: Include dependent entities
13538 db: Database session
13539 user: Authenticated user
13541 Returns:
13542 JSON file download with configuration export
13544 Raises:
13545 HTTPException: If export fails
13546 """
13547 try:
13548 LOGGER.info(f"Admin user {user} requested configuration export")
13550 # Parse parameters
13551 include_types = None
13552 if types:
13553 include_types = [t.strip() for t in types.split(",") if t.strip()]
13555 exclude_types_list = None
13556 if exclude_types:
13557 exclude_types_list = [t.strip() for t in exclude_types.split(",") if t.strip()]
13559 tags_list = None
13560 if tags:
13561 tags_list = [t.strip() for t in tags.split(",") if t.strip()]
13563 # Extract username from user (which could be string or dict with token)
13564 username = user if isinstance(user, str) else user.get("username", "unknown")
13566 # Get root path for URL construction - prefer configured APP_ROOT_PATH
13567 root_path = settings.app_root_path
13569 # Perform export
13570 export_data = await export_service.export_configuration(
13571 db=db,
13572 include_types=include_types,
13573 exclude_types=exclude_types_list,
13574 tags=tags_list,
13575 include_inactive=include_inactive,
13576 include_dependencies=include_dependencies,
13577 exported_by=username,
13578 root_path=root_path,
13579 )
13581 # Generate filename
13582 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
13583 filename = f"mcpgateway-config-export-{timestamp}.json"
13585 # Return as downloadable file
13586 content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode()
13587 return Response(
13588 content=content,
13589 media_type="application/json",
13590 headers={
13591 "Content-Disposition": f'attachment; filename="{filename}"',
13592 },
13593 )
13595 except ExportError as e:
13596 LOGGER.error(f"Admin export failed for user {user}: {str(e)}")
13597 raise HTTPException(status_code=400, detail=str(e))
13598 except Exception as e:
13599 LOGGER.error(f"Unexpected admin export error for user {user}: {str(e)}")
13600 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
13603@admin_router.post("/export/selective")
13604@require_permission("admin.system_config", allow_admin_bypass=False)
13605async def admin_export_selective(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
13606 """
13607 Export selected entities via Admin UI with entity selection.
13609 Args:
13610 request: FastAPI request object
13611 db: Database session
13612 user: Authenticated user
13614 Returns:
13615 JSON file download with selective export data
13617 Raises:
13618 HTTPException: If export fails
13620 Expects JSON body with entity selections:
13621 {
13622 "entity_selections": {
13623 "tools": ["tool1", "tool2"],
13624 "servers": ["server1"]
13625 },
13626 "include_dependencies": true
13627 }
13628 """
13629 try:
13630 LOGGER.info(f"Admin user {user} requested selective configuration export")
13632 body = await _read_request_json(request)
13633 entity_selections = body.get("entity_selections", {})
13634 include_dependencies = body.get("include_dependencies", True)
13636 # Extract username from user (which could be string or dict with token)
13637 username = user if isinstance(user, str) else user.get("username", "unknown")
13639 # Get root path for URL construction - prefer configured APP_ROOT_PATH
13640 root_path = settings.app_root_path
13642 # Perform selective export
13643 export_data = await export_service.export_selective(db=db, entity_selections=entity_selections, include_dependencies=include_dependencies, exported_by=username, root_path=root_path)
13645 # Generate filename
13646 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
13647 filename = f"mcpgateway-selective-export-{timestamp}.json"
13649 # Return as downloadable file
13650 content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode()
13651 return Response(
13652 content=content,
13653 media_type="application/json",
13654 headers={
13655 "Content-Disposition": f'attachment; filename="{filename}"',
13656 },
13657 )
13659 except ExportError as e:
13660 LOGGER.error(f"Admin selective export failed for user {user}: {str(e)}")
13661 raise HTTPException(status_code=400, detail=str(e))
13662 except Exception as e:
13663 LOGGER.error(f"Unexpected admin selective export error for user {user}: {str(e)}")
13664 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
13667@admin_router.post("/import/preview")
13668@require_permission("admin.system_config", allow_admin_bypass=False)
13669async def admin_import_preview(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
13670 """
13671 Preview import file to show available items for selective import.
13673 Args:
13674 request: FastAPI request object with import file data
13675 db: Database session
13676 user: Authenticated user
13678 Returns:
13679 JSON response with categorized import preview data
13681 Raises:
13682 HTTPException: 400 for invalid JSON or missing data field, validation errors;
13683 500 for unexpected preview failures
13685 Expects JSON body:
13686 {
13687 "data": { ... } // The import file content
13688 }
13689 """
13690 try:
13691 LOGGER.info(f"Admin import preview requested by user: {user}")
13693 # Parse request data
13694 try:
13695 data = await _read_request_json(request)
13696 except ValueError as e:
13697 raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
13699 # Extract import data
13700 import_data = data.get("data")
13701 if not import_data:
13702 raise HTTPException(status_code=400, detail="Missing 'data' field with import content")
13704 # Validate user permissions for import preview
13705 username = user if isinstance(user, str) else user.get("username", "unknown")
13706 LOGGER.info(f"Processing import preview for user: {username}")
13708 # Generate preview
13709 preview_data = await import_service.preview_import(db=db, import_data=import_data)
13711 return ORJSONResponse(content={"success": True, "preview": preview_data, "message": f"Import preview generated. Found {preview_data['summary']['total_items']} total items."})
13713 except ImportValidationError as e:
13714 LOGGER.error(f"Import validation failed for user {user}: {str(e)}")
13715 raise HTTPException(status_code=400, detail=f"Invalid import data: {str(e)}")
13716 except Exception as e:
13717 LOGGER.error(f"Import preview failed for user {user}: {str(e)}")
13718 raise HTTPException(status_code=500, detail=f"Preview failed: {str(e)}")
13721@admin_router.post("/import/configuration")
13722@require_permission("admin.system_config", allow_admin_bypass=False)
13723async def admin_import_configuration(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
13724 """
13725 Import configuration via Admin UI.
13727 Args:
13728 request: FastAPI request object
13729 db: Database session
13730 user: Authenticated user
13732 Returns:
13733 JSON response with import status
13735 Raises:
13736 HTTPException: If import fails
13738 Expects JSON body with import data and options:
13739 {
13740 "import_data": { ... },
13741 "conflict_strategy": "update",
13742 "dry_run": false,
13743 "rekey_secret": "optional-new-secret",
13744 "selected_entities": { ... }
13745 }
13746 """
13747 try:
13748 LOGGER.info(f"Admin user {user} requested configuration import")
13750 body = await _read_request_json(request)
13751 import_data = body.get("import_data")
13752 if not import_data:
13753 raise HTTPException(status_code=400, detail="Missing import_data in request body")
13755 conflict_strategy_str = body.get("conflict_strategy", "update")
13756 dry_run = body.get("dry_run", False)
13757 rekey_secret = body.get("rekey_secret")
13758 selected_entities = body.get("selected_entities")
13760 # Validate conflict strategy
13761 try:
13762 conflict_strategy = ConflictStrategy(conflict_strategy_str.lower())
13763 except ValueError:
13764 allowed = [s.value for s in ConflictStrategy.__members__.values()]
13765 raise HTTPException(status_code=400, detail=f"Invalid conflict strategy. Must be one of: {allowed}")
13767 # Extract username from user (which could be string or dict with token)
13768 username = user if isinstance(user, str) else user.get("username", "unknown")
13770 # Perform import
13771 status = await import_service.import_configuration(
13772 db=db, import_data=import_data, conflict_strategy=conflict_strategy, dry_run=dry_run, rekey_secret=rekey_secret, imported_by=username, selected_entities=selected_entities
13773 )
13775 return ORJSONResponse(content=status.to_dict())
13777 except ImportServiceError as e:
13778 LOGGER.error(f"Admin import failed for user {user}: {str(e)}")
13779 raise HTTPException(status_code=400, detail=str(e))
13780 except Exception as e:
13781 LOGGER.error(f"Unexpected admin import error for user {user}: {str(e)}")
13782 raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
13785@admin_router.get("/import/status/{import_id}")
13786@require_permission("admin.system_config", allow_admin_bypass=False)
13787async def admin_get_import_status(import_id: str, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
13788 """Get import status via Admin UI.
13790 Args:
13791 import_id: Import operation ID
13792 user: Authenticated user
13793 _db: Database session for permission checks.
13795 Returns:
13796 JSON response with import status
13798 Raises:
13799 HTTPException: If import not found
13800 """
13801 LOGGER.debug(f"Admin user {user} requested import status for {import_id}")
13803 status = import_service.get_import_status(import_id)
13804 if not status:
13805 raise HTTPException(status_code=404, detail=f"Import {import_id} not found")
13807 return ORJSONResponse(content=status.to_dict())
13810@admin_router.get("/import/status")
13811@require_permission("admin.system_config", allow_admin_bypass=False)
13812async def admin_list_import_statuses(user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
13813 """List all import statuses via Admin UI.
13815 Args:
13816 user: Authenticated user
13817 _db: Database session for permission checks.
13819 Returns:
13820 JSON response with list of import statuses
13821 """
13822 LOGGER.debug(f"Admin user {user} requested all import statuses")
13824 statuses = import_service.list_import_statuses()
13825 return ORJSONResponse(content=[status.to_dict() for status in statuses])
13828# ============================================================================ #
13829# A2A AGENT ADMIN ROUTES #
13830# ============================================================================ #
13833@admin_router.get("/a2a/{agent_id}", response_model=A2AAgentRead)
13834@require_permission("a2a.read", allow_admin_bypass=False)
13835async def admin_get_agent(
13836 agent_id: str,
13837 db: Session = Depends(get_db),
13838 user=Depends(get_current_user_with_permissions),
13839) -> Dict[str, Any]:
13840 """Get A2A agent details for the admin UI.
13842 Args:
13843 agent_id: Agent ID.
13844 db: Database session.
13845 user: Authenticated user.
13847 Returns:
13848 Agent details.
13850 Raises:
13851 HTTPException: If the agent is not found.
13852 Exception: For any other unexpected errors.
13854 Examples:
13855 >>> callable(admin_get_agent)
13856 True
13857 >>> admin_get_agent.__name__
13858 'admin_get_agent'
13859 """
13860 LOGGER.debug(f"User {get_user_email(user)} requested details for agent ID {agent_id}")
13861 try:
13862 agent = await a2a_service.get_agent(db, agent_id)
13863 return agent.model_dump(by_alias=True)
13864 except A2AAgentNotFoundError as e:
13865 raise HTTPException(status_code=404, detail=str(e))
13866 except Exception as e:
13867 LOGGER.error(f"Error getting agent {agent_id}: {e}")
13868 raise e
13871@admin_router.get("/a2a", response_model=PaginatedResponse)
13872@require_permission("a2a.read", allow_admin_bypass=False)
13873async def admin_list_a2a_agents(
13874 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
13875 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
13876 include_inactive: bool = False,
13877 db: Session = Depends(get_db),
13878 user=Depends(get_current_user_with_permissions),
13879) -> Dict[str, Any]:
13880 """
13881 List A2A Agents for the admin UI with pagination support.
13883 This endpoint retrieves a paginated list of A2A (Agent-to-Agent) agents associated with
13884 the current user. Administrators can optionally include inactive agents for
13885 management or auditing purposes. Uses offset-based (page/per_page) pagination.
13887 Args:
13888 page (int): Page number (1-indexed) for offset pagination.
13889 per_page (int): Number of items per page.
13890 include_inactive (bool): Whether to include inactive agents in the results.
13891 db (Session): Database session dependency.
13892 user (dict): Authenticated user dependency.
13894 Returns:
13895 Dict[str, Any]: A dictionary containing:
13896 - data: List of A2A agent records formatted with by_alias=True
13897 - pagination: Pagination metadata
13898 - links: Pagination links (optional)
13900 Raises:
13901 HTTPException (500): If an error occurs while retrieving the agent list.
13903 Examples:
13904 >>> callable(admin_list_a2a_agents)
13905 True
13906 >>> admin_list_a2a_agents.__name__
13907 'admin_list_a2a_agents'
13908 """
13909 if a2a_service is None:
13910 LOGGER.warning("A2A features are disabled, returning empty paginated response")
13911 # First-Party
13913 return {
13914 "data": [],
13915 "pagination": PaginationMeta(page=page, per_page=per_page, total_items=0, total_pages=0, has_next=False, has_prev=False).model_dump(),
13916 "links": None,
13917 }
13919 LOGGER.debug(f"User {get_user_email(user)} requested A2A Agent list (page={page}, per_page={per_page})")
13920 user_email = get_user_email(user)
13922 # Call a2a_service.list_agents with page-based pagination
13923 paginated_result = await a2a_service.list_agents(
13924 db=db,
13925 include_inactive=include_inactive,
13926 page=page,
13927 per_page=per_page,
13928 user_email=user_email,
13929 )
13931 # Return standardized paginated response
13932 return {
13933 "data": [agent.model_dump(by_alias=True) for agent in paginated_result["data"]],
13934 "pagination": paginated_result["pagination"].model_dump(),
13935 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
13936 }
13939@admin_router.post("/a2a")
13940@require_permission("a2a.create", allow_admin_bypass=False)
13941async def admin_add_a2a_agent(
13942 request: Request,
13943 db: Session = Depends(get_db),
13944 user=Depends(get_current_user_with_permissions),
13945) -> JSONResponse:
13946 """Add a new A2A agent via admin UI.
13948 Args:
13949 request: FastAPI request object
13950 db: Database session
13951 user: Authenticated user
13953 Returns:
13954 JSONResponse with success/error status
13956 Raises:
13957 HTTPException: If A2A features are disabled
13958 """
13959 LOGGER.info(f"A2A agent creation request from user {user}")
13961 if not a2a_service or not settings.mcpgateway_a2a_enabled:
13962 LOGGER.warning("A2A agent creation attempted but A2A features are disabled")
13963 return ORJSONResponse(
13964 content={"message": "A2A features are disabled!", "success": False},
13965 status_code=403,
13966 )
13968 form = await request.form()
13969 try:
13970 LOGGER.info(f"A2A agent creation form data: {dict(form)}")
13972 user_email = get_user_email(user)
13973 # Determine personal team for default assignment
13974 team_id = form.get("team_id", None)
13975 team_service = TeamManagementService(db)
13976 team_id = await team_service.verify_team_for_user(user_email, team_id)
13978 # Process tags
13979 ts_val = form.get("tags", "")
13980 tags_str = ts_val if isinstance(ts_val, str) else ""
13981 tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
13983 # Parse auth_headers JSON if present
13984 auth_headers_json = str(form.get("auth_headers"))
13985 auth_headers: list[dict[str, Any]] = []
13986 if auth_headers_json:
13987 try:
13988 auth_headers = orjson.loads(auth_headers_json)
13989 except (orjson.JSONDecodeError, ValueError):
13990 auth_headers = []
13992 # Parse OAuth configuration - support both JSON string and individual form fields
13993 oauth_config_json = str(form.get("oauth_config"))
13994 oauth_config: Optional[dict[str, Any]] = None
13996 LOGGER.info(f"DEBUG: oauth_config_json from form = '{oauth_config_json}'")
13997 LOGGER.info(f"DEBUG: Individual OAuth fields - grant_type='{form.get('oauth_grant_type')}', issuer='{form.get('oauth_issuer')}'")
13999 # Option 1: Pre-assembled oauth_config JSON (from API calls)
14000 if oauth_config_json and oauth_config_json != "None":
14001 try:
14002 oauth_config = orjson.loads(oauth_config_json)
14003 # Encrypt the client secret if present
14004 if oauth_config and "client_secret" in oauth_config:
14005 encryption = get_encryption_service(settings.auth_encryption_secret)
14006 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"])
14007 except (orjson.JSONDecodeError, ValueError) as e:
14008 LOGGER.error(f"Failed to parse OAuth config: {e}")
14009 oauth_config = None
14011 # Option 2: Assemble from individual UI form fields
14012 if not oauth_config:
14013 oauth_grant_type = str(form.get("oauth_grant_type", ""))
14014 oauth_issuer = str(form.get("oauth_issuer", ""))
14015 oauth_token_url = str(form.get("oauth_token_url", ""))
14016 oauth_authorization_url = str(form.get("oauth_authorization_url", ""))
14017 oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
14018 oauth_client_id = str(form.get("oauth_client_id", ""))
14019 oauth_client_secret = str(form.get("oauth_client_secret", ""))
14020 oauth_username = str(form.get("oauth_username", ""))
14021 oauth_password = str(form.get("oauth_password", ""))
14022 oauth_scopes_str = str(form.get("oauth_scopes", ""))
14024 # If any OAuth field is provided, assemble oauth_config
14025 if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]):
14026 oauth_config = {}
14028 if oauth_grant_type:
14029 oauth_config["grant_type"] = oauth_grant_type
14030 if oauth_issuer:
14031 oauth_config["issuer"] = oauth_issuer
14032 if oauth_token_url:
14033 oauth_config["token_url"] = oauth_token_url # OAuthManager expects 'token_url', not 'token_endpoint'
14034 if oauth_authorization_url:
14035 oauth_config["authorization_url"] = oauth_authorization_url # OAuthManager expects 'authorization_url', not 'authorization_endpoint'
14036 if oauth_redirect_uri:
14037 oauth_config["redirect_uri"] = oauth_redirect_uri
14038 if oauth_client_id:
14039 oauth_config["client_id"] = oauth_client_id
14040 if oauth_client_secret:
14041 # Encrypt the client secret
14042 encryption = get_encryption_service(settings.auth_encryption_secret)
14043 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret)
14045 # Add username and password for password grant type
14046 if oauth_username:
14047 oauth_config["username"] = oauth_username
14048 if oauth_password:
14049 oauth_config["password"] = oauth_password
14051 # Parse scopes (comma or space separated)
14052 if oauth_scopes_str:
14053 scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
14054 if scopes:
14055 oauth_config["scopes"] = scopes
14057 LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}")
14058 LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}")
14060 passthrough_headers = str(form.get("passthrough_headers"))
14061 if passthrough_headers and passthrough_headers.strip():
14062 try:
14063 passthrough_headers = orjson.loads(passthrough_headers)
14064 except (orjson.JSONDecodeError, ValueError):
14065 # Fallback to comma-separated parsing
14066 passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
14067 else:
14068 passthrough_headers = None
14070 # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
14071 auth_type_from_form = str(form.get("auth_type", ""))
14072 LOGGER.info(f"DEBUG: auth_type from form: '{auth_type_from_form}', oauth_config present: {oauth_config is not None}")
14073 if oauth_config and not auth_type_from_form:
14074 auth_type_from_form = "oauth"
14075 LOGGER.info("✅ Auto-detected OAuth configuration, setting auth_type='oauth'")
14076 elif oauth_config and auth_type_from_form:
14077 LOGGER.info(f"✅ OAuth config present with explicit auth_type='{auth_type_from_form}'")
14079 agent_data = A2AAgentCreate(
14080 name=form["name"],
14081 description=form.get("description"),
14082 endpoint_url=form["endpoint_url"],
14083 agent_type=form.get("agent_type", "generic"),
14084 auth_type=auth_type_from_form,
14085 auth_username=str(form.get("auth_username", "")),
14086 auth_password=str(form.get("auth_password", "")),
14087 auth_token=str(form.get("auth_token", "")),
14088 auth_header_key=str(form.get("auth_header_key", "")),
14089 auth_header_value=str(form.get("auth_header_value", "")),
14090 auth_headers=auth_headers if auth_headers else None,
14091 oauth_config=oauth_config,
14092 auth_value=form.get("auth_value") if form.get("auth_value") else None,
14093 auth_query_param_key=str(form.get("auth_query_param_key", "")) or None,
14094 auth_query_param_value=str(form.get("auth_query_param_value", "")) or None,
14095 tags=tags,
14096 visibility=form.get("visibility", "private"),
14097 team_id=team_id,
14098 owner_email=user_email,
14099 passthrough_headers=passthrough_headers,
14100 )
14102 LOGGER.info(f"Creating A2A agent: {agent_data.name} at {agent_data.endpoint_url}")
14104 # Extract metadata from request
14105 metadata = MetadataCapture.extract_creation_metadata(request, user)
14107 await a2a_service.register_agent(
14108 db,
14109 agent_data,
14110 created_by=metadata["created_by"],
14111 created_from_ip=metadata["created_from_ip"],
14112 created_via=metadata["created_via"],
14113 created_user_agent=metadata["created_user_agent"],
14114 import_batch_id=metadata["import_batch_id"],
14115 federation_source=metadata["federation_source"],
14116 team_id=team_id,
14117 owner_email=user_email,
14118 visibility=form.get("visibility", "private"),
14119 )
14121 return ORJSONResponse(
14122 content={"message": "A2A agent created successfully!", "success": True},
14123 status_code=200,
14124 )
14126 except CoreValidationError as ex:
14127 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=422)
14128 except A2AAgentNameConflictError as ex:
14129 LOGGER.error(f"A2A agent name conflict: {ex}")
14130 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
14131 except A2AAgentError as ex:
14132 LOGGER.error(f"A2A agent error: {ex}")
14133 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
14134 except IntegrityError as ex:
14135 return ORJSONResponse(
14136 content=ErrorFormatter.format_database_error(ex),
14137 status_code=409,
14138 )
14139 except Exception as ex:
14140 LOGGER.error(f"Error creating A2A agent: {ex}")
14141 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
14144@admin_router.post("/a2a/{agent_id}/edit")
14145@require_permission("a2a.update", allow_admin_bypass=False)
14146async def admin_edit_a2a_agent(
14147 agent_id: str,
14148 request: Request,
14149 db: Session = Depends(get_db),
14150 user=Depends(get_current_user_with_permissions),
14151) -> JSONResponse:
14152 """
14153 Edit an existing A2A agent via the admin UI.
14155 Expects form fields:
14156 - name
14157 - description (optional)
14158 - endpoint_url
14159 - agent_type
14160 - tags (optional, comma-separated)
14161 - auth_type (optional)
14162 - auth_username (optional)
14163 - auth_password (optional)
14164 - auth_token (optional)
14165 - auth_header_key / auth_header_value (optional)
14166 - auth_headers (JSON array, optional)
14167 - oauth_config (JSON string or individual OAuth fields)
14168 - visibility (optional)
14169 - team_id (optional)
14170 - capabilities (JSON, optional)
14171 - config (JSON, optional)
14172 - passthrough_headers: Optional[List[str]]
14174 Args:
14175 agent_id (str): The ID of the agent being edited.
14176 request (Request): The incoming FastAPI request containing form data.
14177 db (Session): Active database session.
14178 user: The authenticated admin user performing the edit.
14180 Returns:
14181 JSONResponse: A JSON response indicating success or failure.
14183 Examples:
14184 >>> callable(admin_edit_a2a_agent)
14185 True
14186 >>> admin_edit_a2a_agent.__name__
14187 'admin_edit_a2a_agent'
14188 """
14190 try:
14191 form = await request.form()
14193 # Normalize tags
14194 tags_raw = str(form.get("tags", ""))
14195 tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
14197 # Visibility
14198 visibility = str(form.get("visibility", "private"))
14200 # Agent Type
14201 agent_type = str(form.get("agent_type", "generic"))
14203 # Capabilities
14204 raw_capabilities = form.get("capabilities")
14205 capabilities = {}
14206 if raw_capabilities:
14207 try:
14208 capabilities = orjson.loads(raw_capabilities)
14209 except (ValueError, orjson.JSONDecodeError):
14210 capabilities = {}
14212 # Config
14213 raw_config = form.get("config")
14214 config = {}
14215 if raw_config:
14216 try:
14217 config = orjson.loads(raw_config)
14218 except (ValueError, orjson.JSONDecodeError):
14219 config = {}
14221 # Parse auth_headers JSON if present
14222 auth_headers_json = str(form.get("auth_headers"))
14223 auth_headers = []
14224 if auth_headers_json:
14225 try:
14226 auth_headers = orjson.loads(auth_headers_json)
14227 except (orjson.JSONDecodeError, ValueError):
14228 auth_headers = []
14230 # Passthrough headers
14231 passthrough_headers = str(form.get("passthrough_headers"))
14232 if passthrough_headers and passthrough_headers.strip():
14233 try:
14234 passthrough_headers = orjson.loads(passthrough_headers)
14235 except (orjson.JSONDecodeError, ValueError):
14236 # Fallback to comma-separated parsing
14237 passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
14238 else:
14239 passthrough_headers = None
14241 # Parse OAuth configuration - support both JSON string and individual form fields
14242 oauth_config_json = str(form.get("oauth_config"))
14243 oauth_config: Optional[dict[str, Any]] = None
14245 # Option 1: Pre-assembled oauth_config JSON (from API calls)
14246 if oauth_config_json and oauth_config_json != "None":
14247 try:
14248 oauth_config = orjson.loads(oauth_config_json)
14249 # Encrypt the client secret if present and not empty
14250 if oauth_config and "client_secret" in oauth_config and oauth_config["client_secret"]:
14251 encryption = get_encryption_service(settings.auth_encryption_secret)
14252 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"])
14253 except (orjson.JSONDecodeError, ValueError) as e:
14254 LOGGER.error(f"Failed to parse OAuth config: {e}")
14255 oauth_config = None
14257 # Option 2: Assemble from individual UI form fields
14258 if not oauth_config:
14259 oauth_grant_type = str(form.get("oauth_grant_type", ""))
14260 oauth_issuer = str(form.get("oauth_issuer", ""))
14261 oauth_token_url = str(form.get("oauth_token_url", ""))
14262 oauth_authorization_url = str(form.get("oauth_authorization_url", ""))
14263 oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
14264 oauth_client_id = str(form.get("oauth_client_id", ""))
14265 oauth_client_secret = str(form.get("oauth_client_secret", ""))
14266 oauth_username = str(form.get("oauth_username", ""))
14267 oauth_password = str(form.get("oauth_password", ""))
14268 oauth_scopes_str = str(form.get("oauth_scopes", ""))
14270 # If any OAuth field is provided, assemble oauth_config
14271 if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]):
14272 oauth_config = {}
14274 if oauth_grant_type:
14275 oauth_config["grant_type"] = oauth_grant_type
14276 if oauth_issuer:
14277 oauth_config["issuer"] = oauth_issuer
14278 if oauth_token_url:
14279 oauth_config["token_url"] = oauth_token_url # OAuthManager expects 'token_url', not 'token_endpoint'
14280 if oauth_authorization_url:
14281 oauth_config["authorization_url"] = oauth_authorization_url # OAuthManager expects 'authorization_url', not 'authorization_endpoint'
14282 if oauth_redirect_uri:
14283 oauth_config["redirect_uri"] = oauth_redirect_uri
14284 if oauth_client_id:
14285 oauth_config["client_id"] = oauth_client_id
14286 if oauth_client_secret:
14287 # Encrypt the client secret
14288 encryption = get_encryption_service(settings.auth_encryption_secret)
14289 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret)
14291 # Add username and password for password grant type
14292 if oauth_username:
14293 oauth_config["username"] = oauth_username
14294 if oauth_password:
14295 oauth_config["password"] = oauth_password
14297 # Parse scopes (comma or space separated)
14298 if oauth_scopes_str:
14299 scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
14300 if scopes:
14301 oauth_config["scopes"] = scopes
14303 LOGGER.info(f"✅ Assembled OAuth config from UI form fields (edit): grant_type={oauth_grant_type}, issuer={oauth_issuer}")
14305 user_email = get_user_email(user)
14306 team_service = TeamManagementService(db)
14307 team_id = await team_service.verify_team_for_user(user_email, form.get("team_id"))
14309 # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
14310 auth_type_from_form = str(form.get("auth_type", ""))
14311 if oauth_config and not auth_type_from_form:
14312 auth_type_from_form = "oauth"
14313 LOGGER.info("Auto-detected OAuth configuration in edit, setting auth_type='oauth'")
14315 agent_update = A2AAgentUpdate(
14316 name=form.get("name"),
14317 description=form.get("description"),
14318 endpoint_url=form.get("endpoint_url"),
14319 agent_type=agent_type,
14320 tags=tags,
14321 auth_type=auth_type_from_form,
14322 auth_username=str(form.get("auth_username", "")),
14323 auth_password=str(form.get("auth_password", "")),
14324 auth_token=str(form.get("auth_token", "")),
14325 auth_header_key=str(form.get("auth_header_key", "")),
14326 auth_header_value=str(form.get("auth_header_value", "")),
14327 auth_value=str(form.get("auth_value", "")),
14328 auth_query_param_key=str(form.get("auth_query_param_key", "")) or None,
14329 auth_query_param_value=str(form.get("auth_query_param_value", "")) or None,
14330 auth_headers=auth_headers if auth_headers else None,
14331 passthrough_headers=passthrough_headers,
14332 oauth_config=oauth_config,
14333 visibility=visibility,
14334 team_id=team_id,
14335 owner_email=user_email,
14336 capabilities=capabilities, # Optional, not editable via UI
14337 config=config, # Optional, not editable via UI
14338 )
14340 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
14341 await a2a_service.update_agent(
14342 db=db,
14343 agent_id=agent_id,
14344 agent_data=agent_update,
14345 modified_by=mod_metadata["modified_by"],
14346 modified_from_ip=mod_metadata["modified_from_ip"],
14347 modified_via=mod_metadata["modified_via"],
14348 modified_user_agent=mod_metadata["modified_user_agent"],
14349 )
14351 return ORJSONResponse({"message": "A2A agent updated successfully", "success": True}, status_code=200)
14353 except ValidationError as ve:
14354 return ORJSONResponse({"message": str(ve), "success": False}, status_code=422)
14355 except IntegrityError as ie:
14356 return ORJSONResponse({"message": str(ie), "success": False}, status_code=409)
14357 except Exception as e:
14358 return ORJSONResponse({"message": str(e), "success": False}, status_code=500)
14361@admin_router.post("/a2a/{agent_id}/state")
14362@require_permission("a2a.update", allow_admin_bypass=False)
14363async def admin_set_a2a_agent_state(
14364 agent_id: str,
14365 request: Request,
14366 db: Session = Depends(get_db),
14367 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
14368) -> RedirectResponse:
14369 """Toggle A2A agent status via admin UI.
14371 Args:
14372 agent_id: Agent ID
14373 request: FastAPI request object
14374 db: Database session
14375 user: Authenticated user
14377 Returns:
14378 Redirect response to admin page with A2A tab
14380 Raises:
14381 HTTPException: If A2A features are disabled
14382 """
14383 if not a2a_service or not settings.mcpgateway_a2a_enabled:
14384 root_path = request.scope.get("root_path", "")
14385 return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
14387 user_email = get_user_email(user)
14388 error_message = None
14389 try:
14390 form = await request.form()
14391 act_val = form.get("activate", "false")
14392 activate = act_val.lower() == "true" if isinstance(act_val, str) else False
14394 await a2a_service.set_agent_state(db, agent_id, activate, user_email=user_email)
14396 except PermissionError as e:
14397 LOGGER.warning(f"Permission denied for user {user_email} setting A2A agent state {agent_id}: {e}")
14398 error_message = str(e)
14399 except A2AAgentNotFoundError as e:
14400 LOGGER.error(f"A2A agent state change failed - not found: {e}")
14401 root_path = request.scope.get("root_path", "")
14402 error_message = "A2A agent not found."
14403 except Exception as e:
14404 LOGGER.error(f"Error setting A2A agent state: {e}")
14405 root_path = request.scope.get("root_path", "")
14406 error_message = "Failed to set state of A2A agent. Please try again."
14408 root_path = request.scope.get("root_path", "")
14410 # Build redirect URL with error message if present
14411 if error_message:
14412 error_param = f"?error={urllib.parse.quote(error_message)}"
14413 return RedirectResponse(f"{root_path}/admin/{error_param}#a2a-agents", status_code=303)
14415 return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
14418@admin_router.post("/a2a/{agent_id}/delete")
14419@require_permission("a2a.delete", allow_admin_bypass=False)
14420async def admin_delete_a2a_agent(
14421 agent_id: str,
14422 request: Request, # pylint: disable=unused-argument
14423 db: Session = Depends(get_db),
14424 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
14425) -> RedirectResponse:
14426 """Delete A2A agent via admin UI.
14428 Args:
14429 agent_id: Agent ID
14430 request: FastAPI request object
14431 db: Database session
14432 user: Authenticated user
14434 Returns:
14435 Redirect response to admin page with A2A tab
14437 Raises:
14438 HTTPException: If A2A features are disabled
14439 """
14440 if not a2a_service or not settings.mcpgateway_a2a_enabled:
14441 root_path = request.scope.get("root_path", "")
14442 return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
14444 form = await request.form()
14445 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
14446 error_message = None
14447 try:
14448 user_email = get_user_email(user)
14449 await a2a_service.delete_agent(db, agent_id, user_email=user_email, purge_metrics=purge_metrics)
14450 except PermissionError as e:
14451 LOGGER.warning(f"Permission denied for user {get_user_email(user)} deleting A2A agent {agent_id}: {e}")
14452 error_message = str(e)
14453 except A2AAgentNotFoundError as e:
14454 LOGGER.error(f"A2A agent delete failed - not found: {e}")
14455 error_message = "A2A agent not found."
14456 except Exception as e:
14457 LOGGER.error(f"Error deleting A2A agent: {e}")
14458 error_message = "Failed to delete A2A agent. Please try again."
14460 root_path = request.scope.get("root_path", "")
14462 # Build redirect URL with error message if present
14463 if error_message:
14464 error_param = f"?error={urllib.parse.quote(error_message)}"
14465 return RedirectResponse(f"{root_path}/admin/{error_param}#a2a-agents", status_code=303)
14467 return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
14470@admin_router.post("/a2a/{agent_id}/test")
14471@require_permission("a2a.invoke", allow_admin_bypass=False)
14472async def admin_test_a2a_agent(
14473 agent_id: str,
14474 request: Request,
14475 db: Session = Depends(get_db),
14476 user=Depends(get_current_user_with_permissions),
14477) -> JSONResponse:
14478 """Test A2A agent via admin UI.
14480 Args:
14481 agent_id: Agent ID
14482 request: FastAPI request object containing optional 'query' field
14483 db: Database session
14484 user: Authenticated user
14486 Returns:
14487 JSON response with test results
14489 Raises:
14490 HTTPException: If A2A features are disabled
14491 """
14492 if not a2a_service or not settings.mcpgateway_a2a_enabled:
14493 return ORJSONResponse(content={"success": False, "error": "A2A features are disabled"}, status_code=403)
14495 try:
14496 user_email = get_user_email(user)
14497 # Get the agent by ID
14498 agent = await a2a_service.get_agent(db, agent_id)
14500 # Parse request body to get user-provided query
14501 default_message = "Hello from MCP Gateway Admin UI test!"
14502 try:
14503 body = await _read_request_json(request)
14504 # Use 'or' to also handle empty string queries
14505 user_query = (body.get("query") if body else None) or default_message
14506 except Exception:
14507 user_query = default_message
14509 # Prepare test parameters based on agent type and endpoint
14510 if agent.agent_type in ["generic", "jsonrpc"] or agent.endpoint_url.endswith("/"):
14511 # JSONRPC format for agents that expect it
14512 test_params = {
14513 "method": "message/send",
14514 # A2A v0.3.x: message.parts use "kind" (not "type").
14515 "params": {
14516 "message": {
14517 "kind": "message",
14518 "messageId": f"admin-test-{int(time.time())}",
14519 "role": "user",
14520 "parts": [{"kind": "text", "text": user_query}],
14521 }
14522 },
14523 }
14524 else:
14525 # Generic test format
14526 test_params = {"query": user_query, "message": user_query, "test": True, "timestamp": int(time.time())}
14528 # Invoke the agent
14529 result = await a2a_service.invoke_agent(
14530 db,
14531 agent.name,
14532 test_params,
14533 "admin_test",
14534 user_email=user_email,
14535 user_id=user_email,
14536 )
14538 return ORJSONResponse(content={"success": True, "result": result, "agent_name": agent.name, "test_timestamp": time.time()})
14540 except Exception as e:
14541 LOGGER.error(f"Error testing A2A agent {agent_id}: {e}")
14542 return ORJSONResponse(content={"success": False, "error": str(e), "agent_id": agent_id}, status_code=500)
14545# gRPC Service Management Endpoints
14548@admin_router.get("/grpc", response_model=PaginatedResponse)
14549@require_permission("admin.grpc", allow_admin_bypass=False)
14550async def admin_list_grpc_services(
14551 page: int = Query(1, ge=1, description="Page number (1-indexed)"),
14552 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
14553 include_inactive: bool = False,
14554 team_id: Optional[str] = Depends(_validated_team_id_param),
14555 db: Session = Depends(get_db),
14556 user=Depends(get_current_user_with_permissions),
14557) -> Dict[str, Any]:
14558 """List all gRPC services for the admin UI with pagination support.
14560 This endpoint retrieves a paginated list of gRPC services. Administrators can
14561 optionally include inactive services for management or auditing purposes.
14562 Uses offset-based (page/per_page) pagination.
14564 Args:
14565 page: Page number (1-indexed) for offset pagination
14566 per_page: Number of items per page
14567 include_inactive: Whether to include inactive services in the results
14568 team_id: Optional team ID to filter by specific team
14569 db: Database session dependency
14570 user: Authenticated user dependency
14572 Returns:
14573 Dict[str, Any]: A dictionary containing:
14574 - data: List of gRPC service records formatted with by_alias=True
14575 - pagination: Pagination metadata
14576 - links: Pagination links (optional)
14578 Raises:
14579 HTTPException: If gRPC support is disabled or not available
14580 """
14581 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
14582 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")
14584 user_email = get_user_email(user)
14586 # Call grpc_service_mgr.list_services with page-based pagination
14587 paginated_result = await grpc_service_mgr.list_services(
14588 db=db,
14589 include_inactive=include_inactive,
14590 page=page,
14591 per_page=per_page,
14592 user_email=user_email,
14593 team_id=team_id,
14594 )
14596 # Return standardized paginated response
14597 return {
14598 "data": [service.model_dump(by_alias=True) for service in paginated_result["data"]],
14599 "pagination": paginated_result["pagination"].model_dump(),
14600 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
14601 }
14604@admin_router.post("/grpc")
14605@require_permission("admin.grpc", allow_admin_bypass=False)
14606async def admin_create_grpc_service(
14607 service: GrpcServiceCreate,
14608 request: Request,
14609 db: Session = Depends(get_db),
14610 user=Depends(get_current_user_with_permissions),
14611):
14612 """Create a new gRPC service.
14614 Args:
14615 service: gRPC service creation data
14616 request: FastAPI request object
14617 db: Database session
14618 user: Authenticated user
14620 Returns:
14621 Created gRPC service
14623 Raises:
14624 HTTPException: If gRPC support is disabled or creation fails
14625 """
14626 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
14627 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")
14629 try:
14630 metadata = MetadataCapture.extract_creation_metadata(request, user)
14631 user_email = get_user_email(user)
14632 result = await grpc_service_mgr.register_service(db, service, user_email, metadata)
14633 return ORJSONResponse(content=jsonable_encoder(result), status_code=201)
14634 except GrpcServiceNameConflictError as e:
14635 raise HTTPException(status_code=409, detail=str(e))
14636 except GrpcServiceError as e:
14637 LOGGER.error(f"gRPC service error: {e}")
14638 raise HTTPException(status_code=500, detail=str(e))
14641@admin_router.get("/grpc/{service_id}", response_model=GrpcServiceRead)
14642@require_permission("admin.grpc", allow_admin_bypass=False)
14643async def admin_get_grpc_service(
14644 service_id: str,
14645 db: Session = Depends(get_db),
14646 user=Depends(get_current_user_with_permissions),
14647):
14648 """Get a specific gRPC service.
14650 Args:
14651 service_id: Service ID
14652 db: Database session
14653 user: Authenticated user
14655 Returns:
14656 The gRPC service
14658 Raises:
14659 HTTPException: If gRPC support is disabled or service not found
14660 """
14661 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
14662 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")
14664 try:
14665 user_email = get_user_email(user)
14666 return await grpc_service_mgr.get_service(db, service_id, user_email)
14667 except GrpcServiceNotFoundError as e:
14668 raise HTTPException(status_code=404, detail=str(e))
14671@admin_router.put("/grpc/{service_id}")
14672@require_permission("admin.grpc", allow_admin_bypass=False)
14673async def admin_update_grpc_service(
14674 service_id: str,
14675 service: GrpcServiceUpdate,
14676 request: Request,
14677 db: Session = Depends(get_db),
14678 user=Depends(get_current_user_with_permissions),
14679):
14680 """Update a gRPC service.
14682 Args:
14683 service_id: Service ID
14684 service: Update data
14685 request: FastAPI request object
14686 db: Database session
14687 user: Authenticated user
14689 Returns:
14690 Updated gRPC service
14692 Raises:
14693 HTTPException: If gRPC support is disabled or update fails
14694 """
14695 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
14696 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")
14698 try:
14699 metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
14700 user_email = get_user_email(user)
14701 result = await grpc_service_mgr.update_service(db, service_id, service, user_email, metadata)
14702 return ORJSONResponse(content=jsonable_encoder(result))
14703 except GrpcServiceNotFoundError as e:
14704 raise HTTPException(status_code=404, detail=str(e))
14705 except GrpcServiceNameConflictError as e:
14706 raise HTTPException(status_code=409, detail=str(e))
14707 except GrpcServiceError as e:
14708 LOGGER.error(f"gRPC service error: {e}")
14709 raise HTTPException(status_code=500, detail=str(e))
14712@admin_router.post("/grpc/{service_id}/state")
14713@require_permission("admin.grpc", allow_admin_bypass=False)
14714async def admin_set_grpc_service_state(
14715 service_id: str,
14716 activate: Optional[bool] = Query(None, description="Set enabled state. If not provided, inverts current state."),
14717 db: Session = Depends(get_db),
14718 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
14719):
14720 """Set a gRPC service's enabled state.
14722 Args:
14723 service_id: Service ID
14724 activate: If provided, sets enabled to this value. If None, inverts current state (legacy behavior).
14725 db: Database session
14726 user: Authenticated user
14728 Returns:
14729 Updated gRPC service
14731 Raises:
14732 HTTPException: If gRPC support is disabled or state change fails
14733 """
14734 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
14735 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")
14737 try:
14738 if activate is None:
14739 # Legacy toggle behavior - invert current state
14740 service = await grpc_service_mgr.get_service(db, service_id)
14741 activate = not service.enabled
14742 result = await grpc_service_mgr.set_service_state(db, service_id, activate)
14743 return ORJSONResponse(content=jsonable_encoder(result))
14744 except GrpcServiceNotFoundError as e:
14745 raise HTTPException(status_code=404, detail=str(e))
14748@admin_router.post("/grpc/{service_id}/delete")
14749@require_permission("admin.grpc", allow_admin_bypass=False)
14750async def admin_delete_grpc_service(
14751 service_id: str,
14752 db: Session = Depends(get_db),
14753 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
14754):
14755 """Delete a gRPC service.
14757 Args:
14758 service_id: Service ID
14759 db: Database session
14760 user: Authenticated user
14762 Returns:
14763 No content response
14765 Raises:
14766 HTTPException: If gRPC support is disabled or deletion fails
14767 """
14768 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
14769 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")
14771 try:
14772 await grpc_service_mgr.delete_service(db, service_id)
14773 return Response(status_code=204)
14774 except GrpcServiceNotFoundError as e:
14775 raise HTTPException(status_code=404, detail=str(e))
14778@admin_router.post("/grpc/{service_id}/reflect")
14779@require_permission("admin.grpc", allow_admin_bypass=False)
14780async def admin_reflect_grpc_service(
14781 service_id: str,
14782 db: Session = Depends(get_db),
14783 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
14784):
14785 """Trigger re-reflection on a gRPC service.
14787 Args:
14788 service_id: Service ID
14789 db: Database session
14790 user: Authenticated user
14792 Returns:
14793 Updated gRPC service with reflection results
14795 Raises:
14796 HTTPException: If gRPC support is disabled or reflection fails
14797 """
14798 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
14799 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")
14801 try:
14802 result = await grpc_service_mgr.reflect_service(db, service_id)
14803 return ORJSONResponse(content=jsonable_encoder(result))
14804 except GrpcServiceNotFoundError as e:
14805 raise HTTPException(status_code=404, detail=str(e))
14806 except GrpcServiceError as e:
14807 LOGGER.error(f"gRPC service error: {e}")
14808 raise HTTPException(status_code=500, detail=str(e))
14811@admin_router.get("/grpc/{service_id}/methods")
14812@require_permission("admin.grpc", allow_admin_bypass=False)
14813async def admin_get_grpc_methods(
14814 service_id: str,
14815 db: Session = Depends(get_db),
14816 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument
14817):
14818 """Get methods for a gRPC service.
14820 Args:
14821 service_id: Service ID
14822 db: Database session
14823 user: Authenticated user
14825 Returns:
14826 List of gRPC methods
14828 Raises:
14829 HTTPException: If gRPC support is disabled or service not found
14830 """
14831 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
14832 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")
14834 try:
14835 methods = await grpc_service_mgr.get_service_methods(db, service_id)
14836 return ORJSONResponse(content={"methods": methods})
14837 except GrpcServiceNotFoundError as e:
14838 raise HTTPException(status_code=404, detail=str(e))
14841@admin_router.get("/sections/resources")
14842@require_permission("resources.read", allow_admin_bypass=False)
14843async def get_resources_section(
14844 team_id: Optional[str] = None,
14845 db: Session = Depends(get_db),
14846 user=Depends(get_current_user_with_permissions),
14847):
14848 """Get resources data filtered by team.
14850 Args:
14851 team_id: Optional team ID to filter by
14852 db: Database session
14853 user: Current authenticated user context
14855 Returns:
14856 JSONResponse: Resources data with team filtering applied
14857 """
14858 try:
14859 local_resource_service = ResourceService()
14860 user_email = get_user_email(user)
14861 LOGGER.debug(f"User {user_email} requesting resources section with team_id={team_id}")
14863 # Get all resources and filter by team
14864 resources_list = await local_resource_service.list_resources(db, include_inactive=True)
14866 # Apply team filtering if specified
14867 if team_id:
14868 resources_list = [r for r in resources_list if getattr(r, "team_id", None) == team_id]
14870 # Convert to JSON-serializable format
14871 resources = []
14872 for resource in resources_list:
14873 resource_dict = (
14874 resource.model_dump(by_alias=True)
14875 if hasattr(resource, "model_dump")
14876 else {
14877 "id": resource.id,
14878 "name": resource.name,
14879 "description": resource.description,
14880 "uri": resource.uri,
14881 "tags": resource.tags or [],
14882 "isActive": resource.enabled,
14883 "team_id": getattr(resource, "team_id", None),
14884 "visibility": getattr(resource, "visibility", "private"),
14885 }
14886 )
14887 resources.append(resource_dict)
14889 return ORJSONResponse(content={"resources": resources, "team_id": team_id})
14891 except Exception as e:
14892 LOGGER.error(f"Error loading resources section: {e}")
14893 return ORJSONResponse(content={"error": str(e)}, status_code=500)
14896@admin_router.get("/sections/prompts")
14897@require_permission("prompts.read", allow_admin_bypass=False)
14898async def get_prompts_section(
14899 team_id: Optional[str] = None,
14900 db: Session = Depends(get_db),
14901 user=Depends(get_current_user_with_permissions),
14902):
14903 """Get prompts data filtered by team.
14905 Args:
14906 team_id: Optional team ID to filter by
14907 db: Database session
14908 user: Current authenticated user context
14910 Returns:
14911 JSONResponse: Prompts data with team filtering applied
14912 """
14913 try:
14914 local_prompt_service = PromptService()
14915 user_email = get_user_email(user)
14916 LOGGER.debug(f"User {user_email} requesting prompts section with team_id={team_id}")
14918 # Get all prompts and filter by team
14919 prompts_list = await local_prompt_service.list_prompts(db, include_inactive=True)
14921 # Apply team filtering if specified
14922 if team_id:
14923 prompts_list = [p for p in prompts_list if getattr(p, "team_id", None) == team_id]
14925 # Convert to JSON-serializable format
14926 prompts = []
14927 for prompt in prompts_list:
14928 prompt_dict = (
14929 prompt.model_dump(by_alias=True)
14930 if hasattr(prompt, "model_dump")
14931 else {
14932 "id": prompt.id,
14933 "name": prompt.name,
14934 "description": prompt.description,
14935 "arguments": prompt.arguments or [],
14936 "tags": prompt.tags or [],
14937 # Prompt enabled/disabled state is stored on the prompt as `enabled`.
14938 "isActive": getattr(prompt, "enabled", False),
14939 "team_id": getattr(prompt, "team_id", None),
14940 "visibility": getattr(prompt, "visibility", "private"),
14941 }
14942 )
14943 prompts.append(prompt_dict)
14945 return ORJSONResponse(content={"prompts": prompts, "team_id": team_id})
14947 except Exception as e:
14948 LOGGER.error(f"Error loading prompts section: {e}")
14949 return ORJSONResponse(content={"error": str(e)}, status_code=500)
14952@admin_router.get("/sections/servers")
14953@require_permission("servers.read", allow_admin_bypass=False)
14954async def get_servers_section(
14955 team_id: Optional[str] = None,
14956 include_inactive: bool = False,
14957 db: Session = Depends(get_db),
14958 user=Depends(get_current_user_with_permissions),
14959):
14960 """Get servers data filtered by team.
14962 Args:
14963 team_id: Optional team ID to filter by
14964 include_inactive: Whether to include inactive servers
14965 db: Database session
14966 user: Current authenticated user context
14968 Returns:
14969 JSONResponse: Servers data with team filtering applied
14970 """
14971 try:
14972 local_server_service = ServerService()
14973 user_email = get_user_email(user)
14974 LOGGER.debug(f"User {user_email} requesting servers section with team_id={team_id}, include_inactive={include_inactive}")
14976 # Get servers with optional include_inactive parameter
14977 servers_list = await local_server_service.list_servers(db, include_inactive=include_inactive)
14979 # Apply team filtering if specified
14980 if team_id:
14981 servers_list = [s for s in servers_list if getattr(s, "team_id", None) == team_id]
14983 # Convert to JSON-serializable format
14984 servers = []
14985 for server in servers_list:
14986 server_dict = (
14987 server.model_dump(by_alias=True)
14988 if hasattr(server, "model_dump")
14989 else {
14990 "id": server.id,
14991 "name": server.name,
14992 "description": server.description,
14993 "tags": server.tags or [],
14994 "isActive": server.enabled,
14995 "team_id": getattr(server, "team_id", None),
14996 "visibility": getattr(server, "visibility", "private"),
14997 }
14998 )
14999 servers.append(server_dict)
15001 return ORJSONResponse(content={"servers": servers, "team_id": team_id})
15003 except Exception as e:
15004 LOGGER.error(f"Error loading servers section: {e}")
15005 return ORJSONResponse(content={"error": str(e)}, status_code=500)
15008@admin_router.get("/sections/gateways")
15009@require_permission("gateways.read", allow_admin_bypass=False)
15010async def get_gateways_section(
15011 team_id: Optional[str] = None,
15012 db: Session = Depends(get_db),
15013 user=Depends(get_current_user_with_permissions),
15014):
15015 """Get gateways data filtered by team.
15017 Args:
15018 team_id: Optional team ID to filter by
15019 db: Database session
15020 user: Current authenticated user context
15022 Returns:
15023 JSONResponse: Gateways data with team filtering applied
15024 """
15025 try:
15026 local_gateway_service = GatewayService()
15027 get_user_email(user)
15029 # Get all gateways and filter by team
15030 gateways_list, _ = await local_gateway_service.list_gateways(db, include_inactive=True)
15032 # Apply team filtering if specified
15033 if team_id:
15034 gateways_list = [g for g in gateways_list if g.team_id == team_id]
15036 # Convert to JSON-serializable format
15037 gateways = []
15038 for gateway in gateways_list:
15039 if hasattr(gateway, "model_dump"):
15040 # Get dict and serialize datetime objects
15041 gateway_dict = gateway.model_dump(by_alias=True)
15042 # Convert datetime objects to strings
15043 for key, value in gateway_dict.items():
15044 gateway_dict[key] = serialize_datetime(value)
15045 else:
15046 # Parse URL to extract host and port
15047 parsed_url = urllib.parse.urlparse(gateway.url) if gateway.url else None
15048 gateway_dict = {
15049 "id": gateway.id,
15050 "name": gateway.name,
15051 "host": parsed_url.hostname if parsed_url else "",
15052 "port": parsed_url.port if parsed_url else 80,
15053 "tags": gateway.tags or [],
15054 "isActive": getattr(gateway, "enabled", False),
15055 "team_id": getattr(gateway, "team_id", None),
15056 "visibility": getattr(gateway, "visibility", "private"),
15057 "created_at": serialize_datetime(getattr(gateway, "created_at", None)),
15058 "updated_at": serialize_datetime(getattr(gateway, "updated_at", None)),
15059 }
15060 gateways.append(gateway_dict)
15062 return ORJSONResponse(content={"gateways": gateways, "team_id": team_id})
15064 except Exception as e:
15065 LOGGER.error(f"Error loading gateways section: {e}")
15066 return ORJSONResponse(content={"error": str(e)}, status_code=500)
15069####################
15070# Plugin Routes #
15071####################
15074@admin_router.get("/plugins/partial")
15075@require_permission("admin.plugins", allow_admin_bypass=False)
15076async def get_plugins_partial(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> HTMLResponse: # pylint: disable=unused-argument
15077 """Render the plugins partial HTML template.
15079 This endpoint returns a rendered HTML partial containing plugin information,
15080 similar to the version_info_partial pattern. It's designed to be loaded via HTMX
15081 into the admin interface.
15083 Args:
15084 request: FastAPI request object
15085 db: Database session
15086 user: Authenticated user
15088 Returns:
15089 HTMLResponse with rendered plugins partial template
15090 """
15091 LOGGER.debug(f"User {get_user_email(user)} requested plugins partial")
15093 try:
15094 # Get plugin service and check if plugins are enabled
15095 plugin_service = get_plugin_service()
15097 # Check if plugin manager is available in app state
15098 plugin_manager = getattr(request.app.state, "plugin_manager", None)
15099 if plugin_manager:
15100 plugin_service.set_plugin_manager(plugin_manager)
15102 # Get plugin data
15103 plugins = plugin_service.get_all_plugins()
15104 stats = await plugin_service.get_plugin_statistics()
15106 # Prepare context for template
15107 context = {"request": request, "plugins": plugins, "stats": stats, "plugins_enabled": plugin_manager is not None, "root_path": request.scope.get("root_path", "")}
15109 # Render the partial template
15110 return request.app.state.templates.TemplateResponse(request, "plugins_partial.html", context)
15112 except Exception as e:
15113 LOGGER.error(f"Error rendering plugins partial: {e}")
15114 # Return error HTML that can be displayed in the UI
15115 error_html = f"""
15116 <div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
15117 <strong class="font-bold">Error loading plugins:</strong>
15118 <span class="block sm:inline">{html.escape(str(e))}</span>
15119 </div>
15120 """
15121 return HTMLResponse(content=error_html, status_code=500)
15124@admin_router.get("/plugins", response_model=PluginListResponse)
15125@require_permission("admin.plugins", allow_admin_bypass=False)
15126async def list_plugins(
15127 request: Request,
15128 search: Optional[str] = None,
15129 mode: Optional[str] = None,
15130 hook: Optional[str] = None,
15131 tag: Optional[str] = None,
15132 db: Session = Depends(get_db), # pylint: disable=unused-argument
15133 user=Depends(get_current_user_with_permissions),
15134) -> PluginListResponse:
15135 """Get list of all plugins with optional filtering.
15137 Args:
15138 request: FastAPI request object
15139 search: Optional text search in name/description/author
15140 mode: Optional filter by mode (enforce/permissive/disabled)
15141 hook: Optional filter by hook type
15142 tag: Optional filter by tag
15143 db: Database session
15144 user: Authenticated user
15146 Returns:
15147 PluginListResponse with list of plugins and statistics
15149 Raises:
15150 HTTPException: If there's an error retrieving plugins
15151 """
15152 LOGGER.debug(f"User {get_user_email(user)} requested plugin list")
15153 structured_logger = get_structured_logger()
15155 try:
15156 # Get plugin service
15157 plugin_service = get_plugin_service()
15159 # Check if plugin manager is available
15160 plugin_manager = getattr(request.app.state, "plugin_manager", None)
15161 if plugin_manager:
15162 plugin_service.set_plugin_manager(plugin_manager)
15164 # Get filtered plugins
15165 if any([search, mode, hook, tag]):
15166 plugins = plugin_service.search_plugins(query=search, mode=mode, hook=hook, tag=tag)
15167 else:
15168 plugins = plugin_service.get_all_plugins()
15170 # Count enabled/disabled
15171 enabled_count = sum(1 for p in plugins if p["status"] == "enabled")
15172 disabled_count = sum(1 for p in plugins if p["status"] == "disabled")
15174 # Log plugin marketplace browsing activity
15175 structured_logger.info(
15176 "User browsed plugin marketplace",
15177 user_id=get_user_id(user),
15178 user_email=get_user_email(user),
15179 component="plugin_marketplace",
15180 category="business_logic",
15181 resource_type="plugin_list",
15182 resource_action="browse",
15183 custom_fields={
15184 "search_query": search,
15185 "filter_mode": mode,
15186 "filter_hook": hook,
15187 "filter_tag": tag,
15188 "results_count": len(plugins),
15189 "enabled_count": enabled_count,
15190 "disabled_count": disabled_count,
15191 "has_filters": any([search, mode, hook, tag]),
15192 },
15193 )
15195 return PluginListResponse(plugins=plugins, total=len(plugins), enabled_count=enabled_count, disabled_count=disabled_count)
15197 except Exception as e:
15198 LOGGER.error(f"Error listing plugins: {e}")
15199 structured_logger.error("Failed to list plugins in marketplace", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic")
15200 raise HTTPException(status_code=500, detail=str(e))
15203@admin_router.get("/plugins/stats", response_model=PluginStatsResponse)
15204@require_permission("admin.plugins", allow_admin_bypass=False)
15205async def get_plugin_stats(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> PluginStatsResponse: # pylint: disable=unused-argument
15206 """Get plugin statistics.
15208 Args:
15209 request: FastAPI request object
15210 db: Database session
15211 user: Authenticated user
15213 Returns:
15214 PluginStatsResponse with aggregated plugin statistics
15216 Raises:
15217 HTTPException: If there's an error getting plugin statistics
15218 """
15219 LOGGER.debug(f"User {get_user_email(user)} requested plugin statistics")
15220 structured_logger = get_structured_logger()
15222 try:
15223 # Get plugin service
15224 plugin_service = get_plugin_service()
15226 # Check if plugin manager is available
15227 plugin_manager = getattr(request.app.state, "plugin_manager", None)
15228 if plugin_manager:
15229 plugin_service.set_plugin_manager(plugin_manager)
15231 # Get statistics
15232 stats = await plugin_service.get_plugin_statistics()
15234 # Log marketplace analytics access
15235 structured_logger.info(
15236 "User accessed plugin marketplace statistics",
15237 user_id=get_user_id(user),
15238 user_email=get_user_email(user),
15239 component="plugin_marketplace",
15240 category="business_logic",
15241 resource_type="plugin_stats",
15242 resource_action="view",
15243 custom_fields={
15244 "total_plugins": stats.get("total_plugins", 0),
15245 "enabled_plugins": stats.get("enabled_plugins", 0),
15246 "disabled_plugins": stats.get("disabled_plugins", 0),
15247 "hooks_count": len(stats.get("plugins_by_hook", {})),
15248 "tags_count": len(stats.get("plugins_by_tag", {})),
15249 "authors_count": len(stats.get("plugins_by_author", {})),
15250 },
15251 )
15253 return PluginStatsResponse(**stats)
15255 except Exception as e:
15256 LOGGER.error(f"Error getting plugin statistics: {e}")
15257 structured_logger.error(
15258 "Failed to get plugin marketplace statistics", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic"
15259 )
15260 raise HTTPException(status_code=500, detail=str(e))
15263@admin_router.get("/plugins/{name}", response_model=PluginDetail)
15264@require_permission("admin.plugins", allow_admin_bypass=False)
15265async def get_plugin_details(name: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> PluginDetail: # pylint: disable=unused-argument
15266 """Get detailed information about a specific plugin.
15268 Args:
15269 name: Plugin name
15270 request: FastAPI request object
15271 db: Database session
15272 user: Authenticated user
15274 Returns:
15275 PluginDetail with full plugin information
15277 Raises:
15278 HTTPException: If plugin not found
15279 """
15280 LOGGER.debug(f"User {get_user_email(user)} requested details for plugin {name}")
15281 structured_logger = get_structured_logger()
15282 audit_service = get_audit_trail_service()
15284 try:
15285 # Get plugin service
15286 plugin_service = get_plugin_service()
15288 # Check if plugin manager is available
15289 plugin_manager = getattr(request.app.state, "plugin_manager", None)
15290 if plugin_manager:
15291 plugin_service.set_plugin_manager(plugin_manager)
15293 # Get plugin details
15294 plugin = plugin_service.get_plugin_by_name(name)
15296 if not plugin:
15297 structured_logger.warning(
15298 f"Plugin '{name}' not found in marketplace",
15299 user_id=get_user_id(user),
15300 user_email=get_user_email(user),
15301 component="plugin_marketplace",
15302 category="business_logic",
15303 custom_fields={"plugin_name": name, "action": "view_details"},
15304 )
15305 raise HTTPException(status_code=404, detail=f"Plugin '{name}' not found")
15307 # Log plugin view activity
15308 structured_logger.info(
15309 f"User viewed plugin details: '{name}'",
15310 user_id=get_user_id(user),
15311 user_email=get_user_email(user),
15312 component="plugin_marketplace",
15313 category="business_logic",
15314 resource_type="plugin",
15315 resource_id=name,
15316 resource_action="view_details",
15317 custom_fields={
15318 "plugin_name": name,
15319 "plugin_version": plugin.get("version"),
15320 "plugin_author": plugin.get("author"),
15321 "plugin_status": plugin.get("status"),
15322 "plugin_mode": plugin.get("mode"),
15323 "plugin_hooks": plugin.get("hooks", []),
15324 "plugin_tags": plugin.get("tags", []),
15325 },
15326 )
15328 # Create audit trail for plugin access
15329 audit_service.log_audit(
15330 user_id=get_user_id(user), user_email=get_user_email(user), resource_type="plugin", resource_id=name, action="view", description=f"Viewed plugin '{name}' details in marketplace", db=db
15331 )
15333 return PluginDetail(**plugin)
15335 except HTTPException:
15336 raise
15337 except Exception as e:
15338 LOGGER.error(f"Error getting plugin details: {e}")
15339 structured_logger.error(
15340 f"Failed to get plugin details: '{name}'", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic"
15341 )
15342 raise HTTPException(status_code=500, detail=str(e))
15345##################################################
15346# MCP Registry Endpoints
15347##################################################
15350@admin_router.get("/mcp-registry/servers", response_model=CatalogListResponse)
15351@require_permission("servers.read", allow_admin_bypass=False)
15352async def list_catalog_servers(
15353 _request: Request,
15354 category: Optional[str] = None,
15355 auth_type: Optional[str] = None,
15356 provider: Optional[str] = None,
15357 search: Optional[str] = None,
15358 tags: Optional[List[str]] = Query(None),
15359 show_registered_only: bool = False,
15360 show_available_only: bool = True,
15361 limit: int = 100,
15362 offset: int = 0,
15363 db: Session = Depends(get_db),
15364 _user=Depends(get_current_user_with_permissions),
15365) -> CatalogListResponse:
15366 """Get list of catalog servers with filtering.
15368 Args:
15369 _request: FastAPI request object
15370 category: Filter by category
15371 auth_type: Filter by authentication type
15372 provider: Filter by provider
15373 search: Search in name/description
15374 tags: Filter by tags
15375 show_registered_only: Show only already registered servers
15376 show_available_only: Show only available servers
15377 limit: Maximum results
15378 offset: Pagination offset
15379 db: Database session
15380 _user: Authenticated user
15382 Returns:
15383 List of catalog servers matching filters
15385 Raises:
15386 HTTPException: If the catalog feature is disabled.
15387 """
15388 if not settings.mcpgateway_catalog_enabled:
15389 raise HTTPException(status_code=404, detail="Catalog feature is disabled")
15391 catalog_request = CatalogListRequest(
15392 category=category,
15393 auth_type=auth_type,
15394 provider=provider,
15395 search=search,
15396 tags=tags or [],
15397 show_registered_only=show_registered_only,
15398 show_available_only=show_available_only,
15399 limit=limit,
15400 offset=offset,
15401 )
15403 return await catalog_service.get_catalog_servers(catalog_request, db)
15406@admin_router.post("/mcp-registry/{server_id}/register", response_model=CatalogServerRegisterResponse)
15407@require_permission("servers.create", allow_admin_bypass=False)
15408async def register_catalog_server(
15409 server_id: str,
15410 http_request: Request,
15411 request: Optional[CatalogServerRegisterRequest] = None,
15412 db: Session = Depends(get_db),
15413 _user=Depends(get_current_user_with_permissions),
15414) -> Union[CatalogServerRegisterResponse, HTMLResponse]:
15415 """Register a catalog server.
15417 Args:
15418 server_id: Catalog server ID to register
15419 http_request: FastAPI request object (for HTMX detection)
15420 request: Optional registration parameters
15421 db: Database session
15422 _user: Authenticated user
15424 Returns:
15425 Registration response with success status (JSON or HTML)
15427 Raises:
15428 HTTPException: If the catalog feature is disabled.
15429 """
15430 if not settings.mcpgateway_catalog_enabled:
15431 raise HTTPException(status_code=404, detail="Catalog feature is disabled")
15433 result = await catalog_service.register_catalog_server(catalog_id=server_id, request=request, db=db)
15435 # Check if this is an HTMX request
15436 is_htmx = http_request.headers.get("HX-Request") == "true"
15438 if is_htmx:
15439 # Return HTML fragment for HTMX - properly escape all dynamic values
15440 safe_server_id = html.escape(server_id, quote=True)
15441 safe_message = html.escape(result.message, quote=True)
15443 if result.success:
15444 # Check if this is an OAuth server requiring configuration (use explicit flag, not string matching)
15445 if result.oauth_required:
15446 # OAuth servers are registered but disabled until configured
15447 button_fragment = f"""
15448 <button
15449 class="w-full px-4 py-2 bg-yellow-600 text-white rounded-md cursor-default"
15450 disabled
15451 title="{safe_message}"
15452 >
15453 <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
15454 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
15455 </svg>
15456 OAuth Config Required
15457 </button>
15458 """
15459 # Trigger refresh - template will show yellow state from requires_oauth_config field
15460 response = HTMLResponse(content=button_fragment)
15461 response.headers["HX-Trigger-After-Swap"] = orjson.dumps({"catalogRegistrationSuccess": {"delayMs": 1500}}).decode()
15462 return response
15463 # Success: Show success button state
15464 button_fragment = f"""
15465 <button
15466 class="w-full px-4 py-2 bg-green-600 text-white rounded-md cursor-default"
15467 disabled
15468 title="{safe_message}"
15469 >
15470 <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
15471 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
15472 </svg>
15473 Registered Successfully
15474 </button>
15475 """
15476 # Only non-OAuth success triggers delayed table refresh
15477 response = HTMLResponse(content=button_fragment)
15478 response.headers["HX-Trigger-After-Swap"] = orjson.dumps({"catalogRegistrationSuccess": {"delayMs": 1500}}).decode()
15479 return response
15480 # Error: Show error state with retry button (no auto-refresh so retry persists)
15481 error_msg = html.escape(result.error or result.message, quote=True)
15482 button_fragment = f"""
15483 <button
15484 id="{safe_server_id}-register-btn"
15485 class="w-full px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
15486 hx-post="{settings.app_root_path}/admin/mcp-registry/{safe_server_id}/register"
15487 hx-target="#{safe_server_id}-button-container"
15488 hx-swap="innerHTML"
15489 hx-disabled-elt="this"
15490 hx-on::before-request="this.innerHTML = '<span class=\\'inline-flex items-center\\'><span class=\\'inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2\\'></span>Retrying...</span>'"
15491 hx-on::response-error="this.innerHTML = '<span class=\\'inline-flex items-center\\'><svg class=\\'inline-block h-4 w-4 mr-2\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\\'></path></svg>Network Error - Click to Retry</span>'"
15492 title="{error_msg}"
15493 >
15494 <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
15495 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
15496 </svg>
15497 Failed - Click to Retry
15498 </button>
15499 """
15500 # No HX-Trigger for errors - let the retry button persist
15501 return HTMLResponse(content=button_fragment)
15503 # Return JSON for non-HTMX requests (API clients)
15504 return result
15507@admin_router.get("/mcp-registry/{server_id}/status", response_model=CatalogServerStatusResponse)
15508@require_permission("servers.read", allow_admin_bypass=False)
15509async def check_catalog_server_status(
15510 server_id: str,
15511 _db: Session = Depends(get_db),
15512 _user=Depends(get_current_user_with_permissions),
15513) -> CatalogServerStatusResponse:
15514 """Check catalog server availability.
15516 Args:
15517 server_id: Catalog server ID to check
15518 _db: Database session
15519 _user: Authenticated user
15521 Returns:
15522 Server status including availability and response time
15524 Raises:
15525 HTTPException: If the catalog feature is disabled.
15526 """
15527 if not settings.mcpgateway_catalog_enabled:
15528 raise HTTPException(status_code=404, detail="Catalog feature is disabled")
15530 return await catalog_service.check_server_availability(server_id)
15533@admin_router.post("/mcp-registry/bulk-register", response_model=CatalogBulkRegisterResponse)
15534@require_permission("servers.create", allow_admin_bypass=False)
15535async def bulk_register_catalog_servers(
15536 request: CatalogBulkRegisterRequest,
15537 db: Session = Depends(get_db),
15538 _user=Depends(get_current_user_with_permissions),
15539) -> CatalogBulkRegisterResponse:
15540 """Register multiple catalog servers at once.
15542 Args:
15543 request: Bulk registration request with server IDs
15544 db: Database session
15545 _user: Authenticated user
15547 Returns:
15548 Bulk registration response with success/failure details
15550 Raises:
15551 HTTPException: If the catalog feature is disabled.
15552 """
15553 if not settings.mcpgateway_catalog_enabled:
15554 raise HTTPException(status_code=404, detail="Catalog feature is disabled")
15556 return await catalog_service.bulk_register_servers(request, db)
15559@admin_router.get("/mcp-registry/partial")
15560@require_permission("servers.read", allow_admin_bypass=False)
15561async def catalog_partial(
15562 request: Request,
15563 category: Optional[str] = None,
15564 auth_type: Optional[str] = None,
15565 search: Optional[str] = None,
15566 page: int = 1,
15567 db: Session = Depends(get_db),
15568 _user=Depends(get_current_user_with_permissions),
15569) -> HTMLResponse:
15570 """Get HTML partial for catalog servers (used by HTMX).
15572 Args:
15573 request: FastAPI request object
15574 category: Filter by category
15575 auth_type: Filter by authentication type
15576 search: Search term
15577 page: Page number (1-indexed)
15578 db: Database session
15579 _user: Authenticated user
15581 Returns:
15582 HTML partial with filtered catalog servers
15584 Raises:
15585 HTTPException: If the catalog feature is disabled.
15586 """
15587 if not settings.mcpgateway_catalog_enabled:
15588 raise HTTPException(status_code=404, detail="Catalog feature is disabled")
15590 root_path = request.scope.get("root_path", "")
15592 # Calculate pagination
15593 page_size = settings.mcpgateway_catalog_page_size
15594 offset = (page - 1) * page_size
15596 catalog_request = CatalogListRequest(category=category, auth_type=auth_type, search=search, show_available_only=False, limit=page_size, offset=offset)
15598 response = await catalog_service.get_catalog_servers(catalog_request, db)
15600 # Get ALL servers (no filters, no pagination) for counting statistics
15601 all_servers_request = CatalogListRequest(show_available_only=False, limit=1000, offset=0)
15602 all_servers_response = await catalog_service.get_catalog_servers(all_servers_request, db)
15604 # Pass filter parameters to template for pagination links
15605 filter_params = {
15606 "category": category,
15607 "auth_type": auth_type,
15608 "search": search,
15609 }
15611 # Calculate statistics and pagination info
15612 total_servers = response.total
15613 registered_count = sum(1 for s in response.servers if s.is_registered)
15614 total_pages = (total_servers + page_size - 1) // page_size # Ceiling division
15616 # Count ALL servers by category, auth type, and provider (not just current page)
15617 servers_by_category = {}
15618 servers_by_auth_type = {}
15619 servers_by_provider = {}
15621 for server in all_servers_response.servers:
15622 servers_by_category[server.category] = servers_by_category.get(server.category, 0) + 1
15623 servers_by_auth_type[server.auth_type] = servers_by_auth_type.get(server.auth_type, 0) + 1
15624 servers_by_provider[server.provider] = servers_by_provider.get(server.provider, 0) + 1
15626 stats = {
15627 "total_servers": all_servers_response.total, # Use total from all servers
15628 "registered_servers": registered_count,
15629 "categories": all_servers_response.categories,
15630 "auth_types": all_servers_response.auth_types,
15631 "providers": all_servers_response.providers,
15632 "servers_by_category": servers_by_category,
15633 "servers_by_auth_type": servers_by_auth_type,
15634 "servers_by_provider": servers_by_provider,
15635 }
15637 context = {
15638 "request": request,
15639 "servers": response.servers,
15640 "stats": stats,
15641 "root_path": root_path,
15642 "page": page,
15643 "total_pages": total_pages,
15644 "page_size": page_size,
15645 "filter_params": filter_params,
15646 }
15648 return request.app.state.templates.TemplateResponse(request, "mcp_registry_partial.html", context)
15651# ===================================
15652# System Metrics Endpoints
15653# ===================================
15656@admin_router.get("/system/stats")
15657@require_permission("admin.system_config", allow_admin_bypass=False)
15658async def get_system_stats(
15659 request: Request,
15660 db: Session = Depends(get_db),
15661 user=Depends(get_current_user_with_permissions),
15662):
15663 """Get comprehensive system metrics for administrators.
15665 Returns detailed counts across all entity types including users, teams,
15666 MCP resources (servers, tools, resources, prompts, A2A agents, gateways),
15667 API tokens, sessions, metrics, security events, and workflow state.
15669 Designed for capacity planning, performance optimization, and demonstrating
15670 system capabilities to administrators.
15672 Args:
15673 request: FastAPI request object
15674 db: Database session dependency
15675 user: Authenticated user from dependency (must have admin access)
15677 Returns:
15678 HTMLResponse or JSONResponse: Comprehensive system metrics
15679 Returns HTML partial when requested via HTMX, JSON otherwise
15681 Raises:
15682 HTTPException: If metrics collection fails
15684 Examples:
15685 >>> # Request system metrics via API
15686 >>> # GET /admin/system/stats
15687 >>> # Returns JSON with users, teams, mcp_resources, tokens, sessions, metrics, security, workflow
15688 """
15689 try:
15690 LOGGER.info(f"System metrics requested by user: {user}")
15692 # First-Party
15693 from mcpgateway.services.system_stats_service import SystemStatsService # pylint: disable=import-outside-toplevel
15695 # Get metrics (using cached version for performance)
15696 service = SystemStatsService()
15697 stats = await service.get_comprehensive_stats_cached(db)
15699 LOGGER.info(f"System metrics retrieved successfully for user {user}")
15701 # Check if this is an HTMX request for HTML partial
15702 if request.headers.get("hx-request"):
15703 # Return HTML partial for HTMX
15704 return request.app.state.templates.TemplateResponse(
15705 request,
15706 "metrics_partial.html",
15707 {
15708 "request": request,
15709 "stats": stats,
15710 "root_path": request.scope.get("root_path", ""),
15711 "db_metrics_recording_enabled": settings.db_metrics_recording_enabled,
15712 },
15713 )
15715 # Return JSON for API requests
15716 return ORJSONResponse(content=stats)
15718 except Exception as e:
15719 LOGGER.error(f"System metrics retrieval failed for user {user}: {str(e)}", exc_info=True)
15720 raise HTTPException(status_code=500, detail=f"Failed to retrieve system metrics: {str(e)}")
15723# ===================================
15724# Support Bundle Endpoints
15725# ===================================
15728@admin_router.get("/support-bundle/generate")
15729@require_permission("admin.system_config", allow_admin_bypass=False)
15730async def admin_generate_support_bundle(
15731 log_lines: int = Query(default=1000, description="Number of log lines to include"),
15732 include_logs: bool = Query(default=True, description="Include log files"),
15733 include_env: bool = Query(default=True, description="Include environment config"),
15734 include_system: bool = Query(default=True, description="Include system info"),
15735 user=Depends(get_current_user_with_permissions),
15736 _db: Session = Depends(get_db),
15737):
15738 """
15739 Generate and download a support bundle with sanitized diagnostics.
15741 Creates a ZIP file containing version info, system diagnostics, configuration,
15742 and logs with automatic sanitization of sensitive data (passwords, tokens, secrets).
15744 Args:
15745 log_lines: Number of log lines to include (default: 1000, 0 = all)
15746 include_logs: Include log files in bundle (default: True)
15747 include_env: Include environment configuration (default: True)
15748 include_system: Include system diagnostics (default: True)
15749 user: Authenticated user from dependency
15750 _db: Database session for permission checks.
15752 Returns:
15753 Response: ZIP file download with support bundle
15755 Raises:
15756 HTTPException: If bundle generation fails
15758 Examples:
15759 >>> # Request support bundle via API
15760 >>> # GET /admin/support-bundle/generate?log_lines=500
15761 >>> # Returns: mcpgateway-support-YYYY-MM-DD-HHMMSS.zip
15762 """
15763 try:
15764 LOGGER.info(f"Support bundle generation requested by user: {user}")
15766 # First-Party
15767 from mcpgateway.services.support_bundle_service import SupportBundleConfig, SupportBundleService # pylint: disable=import-outside-toplevel
15769 # Create configuration
15770 config = SupportBundleConfig(
15771 include_logs=include_logs,
15772 include_env=include_env,
15773 include_system_info=include_system,
15774 log_tail_lines=log_lines,
15775 output_dir=Path(tempfile.gettempdir()),
15776 )
15778 # Generate bundle
15779 service = SupportBundleService()
15780 bundle_path = service.generate_bundle(config)
15782 # Return as downloadable file using FileResponse (streams asynchronously)
15783 timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
15784 filename = f"mcpgateway-support-{timestamp}.zip"
15786 # Pre-stat for Content-Length header and logging
15787 bundle_stat = bundle_path.stat()
15788 LOGGER.info(f"Support bundle generated successfully for user {user}: {filename} ({bundle_stat.st_size} bytes)")
15790 # Use BackgroundTask to clean up temp file after response is sent
15791 return FileResponse(
15792 path=bundle_path,
15793 media_type="application/zip",
15794 filename=filename,
15795 stat_result=bundle_stat,
15796 background=BackgroundTask(lambda: bundle_path.unlink(missing_ok=True)),
15797 )
15799 except Exception as e:
15800 LOGGER.error(f"Support bundle generation failed for user {user}: {str(e)}", exc_info=True)
15801 raise HTTPException(status_code=500, detail=f"Failed to generate support bundle: {str(e)}")
15804# ============================================================================
15805# Maintenance Routes (Platform Admin Only)
15806# ============================================================================
15809@admin_router.get("/maintenance/partial", response_class=HTMLResponse)
15810@require_permission("admin.system_config", allow_admin_bypass=False)
15811async def get_maintenance_partial(
15812 request: Request,
15813 _user=Depends(get_current_user_with_permissions),
15814 _db: Session = Depends(get_db),
15815):
15816 """Render the maintenance dashboard partial (platform admin only).
15818 This endpoint returns the maintenance UI panel which includes:
15819 - Metrics cleanup controls
15820 - Metrics rollup controls
15821 - System health status
15823 Only platform administrators can access this endpoint.
15825 Args:
15826 request: FastAPI request object
15827 _user: Authenticated user with admin permissions
15828 _db: Database session for permission checks.
15830 Returns:
15831 HTMLResponse: Rendered maintenance dashboard template
15833 Raises:
15834 HTTPException: 403 if user is not a platform admin
15835 """
15836 root_path = request.scope.get("root_path", "")
15838 # Build payload with settings for the template
15839 payload = {
15840 "settings": {
15841 "metrics_cleanup_enabled": getattr(settings, "metrics_cleanup_enabled", False),
15842 "metrics_rollup_enabled": getattr(settings, "metrics_rollup_enabled", False),
15843 "metrics_retention_days": getattr(settings, "metrics_retention_days", 30),
15844 }
15845 }
15847 return request.app.state.templates.TemplateResponse(
15848 request,
15849 "maintenance_partial.html",
15850 {"request": request, "payload": payload, "root_path": root_path},
15851 )
15854# ============================================================================
15855# Observability Routes
15856# ============================================================================
15859@admin_router.get("/observability/partial", response_class=HTMLResponse)
15860@require_permission("admin.system_config", allow_admin_bypass=False)
15861async def get_observability_partial(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
15862 """Render the observability dashboard partial.
15864 Args:
15865 request: FastAPI request object
15866 _user: Authenticated user with admin permissions (required by dependency)
15867 _db: Database session for permission checks.
15869 Returns:
15870 HTMLResponse: Rendered observability dashboard template
15871 """
15872 root_path = request.scope.get("root_path", "")
15873 return request.app.state.templates.TemplateResponse(request, "observability_partial.html", {"request": request, "root_path": root_path})
15876@admin_router.get("/observability/metrics/partial", response_class=HTMLResponse)
15877@require_permission("admin.system_config", allow_admin_bypass=False)
15878async def get_observability_metrics_partial(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
15879 """Render the advanced metrics dashboard partial.
15881 Args:
15882 request: FastAPI request object
15883 _user: Authenticated user with admin permissions (required by dependency)
15884 _db: Database session for permission checks.
15886 Returns:
15887 HTMLResponse: Rendered metrics dashboard template
15888 """
15889 root_path = request.scope.get("root_path", "")
15890 return request.app.state.templates.TemplateResponse(request, "observability_metrics.html", {"request": request, "root_path": root_path})
15893@admin_router.get("/observability/stats", response_class=HTMLResponse)
15894@require_permission("admin.system_config", allow_admin_bypass=False)
15895async def get_observability_stats(request: Request, hours: int = Query(24, ge=1, le=168), _user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)):
15896 """Get observability statistics for the dashboard.
15898 Args:
15899 request: FastAPI request object
15900 hours: Number of hours to look back for statistics (1-168)
15901 _user: Authenticated user with admin permissions (required by dependency)
15902 db: Database session for permission checks.
15904 Returns:
15905 HTMLResponse: Rendered statistics template with trace counts and averages
15906 """
15907 db = next(get_db())
15908 try:
15909 cutoff_time = datetime.now() - timedelta(hours=hours)
15911 # Consolidate multiple count queries into a single aggregated select
15912 # Filter by start_time first (uses index), then aggregate by status
15913 result = db.execute(
15914 select(
15915 func.count(ObservabilityTrace.trace_id).label("total_traces"), # pylint: disable=not-callable
15916 func.sum(case((ObservabilityTrace.status == "ok", 1), else_=0)).label("success_count"),
15917 func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)).label("error_count"),
15918 func.avg(ObservabilityTrace.duration_ms).label("avg_duration_ms"),
15919 ).where(ObservabilityTrace.start_time >= cutoff_time)
15920 ).one()
15922 stats = {
15923 "total_traces": int(result.total_traces or 0),
15924 "success_count": int(result.success_count or 0),
15925 "error_count": int(result.error_count or 0),
15926 "avg_duration_ms": float(result.avg_duration_ms or 0),
15927 }
15929 return request.app.state.templates.TemplateResponse(request, "observability_stats.html", {"request": request, "stats": stats})
15930 finally:
15931 # Ensure close() always runs even if commit() fails
15932 try:
15933 db.commit() # Commit read-only transaction to avoid implicit rollback
15934 finally:
15935 db.close()
15938@admin_router.get("/observability/traces", response_class=HTMLResponse)
15939@require_permission("admin.system_config", allow_admin_bypass=False)
15940async def get_observability_traces(
15941 request: Request,
15942 time_range: str = Query("24h"),
15943 status_filter: str = Query("all"),
15944 limit: int = Query(50),
15945 min_duration: Optional[float] = Query(None),
15946 max_duration: Optional[float] = Query(None),
15947 http_method: Optional[str] = Query(None),
15948 user_email: Optional[str] = Query(None),
15949 name_search: Optional[str] = Query(None),
15950 attribute_search: Optional[str] = Query(None),
15951 tool_name: Optional[str] = Query(None),
15952 _user=Depends(get_current_user_with_permissions),
15953 db: Session = Depends(get_db),
15954):
15955 """Get list of traces for the dashboard.
15957 Args:
15958 request: FastAPI request object
15959 time_range: Time range filter (1h, 6h, 24h, 7d)
15960 status_filter: Status filter (all, ok, error)
15961 limit: Maximum number of traces to return
15962 min_duration: Minimum duration in ms
15963 max_duration: Maximum duration in ms
15964 http_method: HTTP method filter
15965 user_email: User email filter
15966 name_search: Trace name search
15967 attribute_search: Full-text attribute search
15968 tool_name: Filter by tool name (shows traces that invoked this tool)
15969 _user: Authenticated user with admin permissions (required by dependency)
15970 db: Database session for permission checks.
15972 Returns:
15973 HTMLResponse: Rendered traces list template
15974 """
15975 db = next(get_db())
15976 try:
15977 # Parse time range
15978 time_map = {"1h": 1, "6h": 6, "24h": 24, "7d": 168}
15979 hours = time_map.get(time_range, 24)
15980 cutoff_time = datetime.now() - timedelta(hours=hours)
15982 query = db.query(ObservabilityTrace).filter(ObservabilityTrace.start_time >= cutoff_time)
15984 # Apply status filter
15985 if status_filter != "all":
15986 query = query.filter(ObservabilityTrace.status == status_filter)
15988 # Apply duration filters
15989 if min_duration is not None:
15990 query = query.filter(ObservabilityTrace.duration_ms >= min_duration)
15991 if max_duration is not None:
15992 query = query.filter(ObservabilityTrace.duration_ms <= max_duration)
15994 # Apply HTTP method filter
15995 if http_method:
15996 query = query.filter(ObservabilityTrace.http_method == http_method)
15998 # Apply user email filter
15999 if user_email:
16000 query = query.filter(ObservabilityTrace.user_email.ilike(f"%{user_email}%"))
16002 # Apply name search
16003 if name_search:
16004 query = query.filter(ObservabilityTrace.name.ilike(f"%{name_search}%"))
16006 # Apply attribute search
16007 if attribute_search:
16008 # Escape special characters for SQL LIKE
16009 safe_search = attribute_search.replace("%", "\\%").replace("_", "\\_")
16010 query = query.filter(cast(ObservabilityTrace.attributes, String).ilike(f"%{safe_search}%"))
16012 # Apply tool name filter (join with spans to find traces that invoked a specific tool)
16013 if tool_name:
16014 # Subquery to find trace_ids that have tool invocations matching the tool name
16015 tool_trace_ids = (
16016 db.query(ObservabilitySpan.trace_id)
16017 .filter(
16018 ObservabilitySpan.name == "tool.invoke",
16019 extract_json_field(ObservabilitySpan.attributes, '$."tool.name"').ilike(f"%{tool_name}%"),
16020 )
16021 .distinct()
16022 .subquery()
16023 )
16024 query = query.filter(ObservabilityTrace.trace_id.in_(select(tool_trace_ids.c.trace_id)))
16026 # Get traces ordered by most recent
16027 traces = query.order_by(ObservabilityTrace.start_time.desc()).limit(limit).all()
16029 root_path = request.scope.get("root_path", "")
16030 return request.app.state.templates.TemplateResponse(request, "observability_traces_list.html", {"request": request, "traces": traces, "root_path": root_path})
16031 finally:
16032 # Ensure close() always runs even if commit() fails
16033 try:
16034 db.commit() # Commit read-only transaction to avoid implicit rollback
16035 finally:
16036 db.close()
16039@admin_router.get("/observability/trace/{trace_id}", response_class=HTMLResponse)
16040@require_permission("admin.system_config", allow_admin_bypass=False)
16041async def get_observability_trace_detail(request: Request, trace_id: str, _user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)):
16042 """Get detailed trace information with spans.
16044 Args:
16045 request: FastAPI request object
16046 trace_id: UUID of the trace to retrieve
16047 _user: Authenticated user with admin permissions (required by dependency)
16048 db: Database session for permission checks.
16050 Returns:
16051 HTMLResponse: Rendered trace detail template with waterfall view
16053 Raises:
16054 HTTPException: 404 if trace not found
16055 """
16056 db = next(get_db())
16057 try:
16058 trace = db.query(ObservabilityTrace).filter_by(trace_id=trace_id).options(joinedload(ObservabilityTrace.spans).joinedload(ObservabilitySpan.events)).first()
16060 if not trace:
16061 raise HTTPException(status_code=404, detail="Trace not found")
16063 root_path = request.scope.get("root_path", "")
16064 return request.app.state.templates.TemplateResponse(request, "observability_trace_detail.html", {"request": request, "trace": trace, "root_path": root_path})
16065 finally:
16066 # Ensure close() always runs even if commit() fails
16067 try:
16068 db.commit() # Commit read-only transaction to avoid implicit rollback
16069 finally:
16070 db.close()
16073@admin_router.post("/observability/queries", response_model=dict)
16074@require_permission("admin.system_config", allow_admin_bypass=False)
16075async def save_observability_query(
16076 request: Request, # pylint: disable=unused-argument
16077 name: str = Body(..., description="Name for the saved query"),
16078 description: Optional[str] = Body(None, description="Optional description"),
16079 filter_config: dict = Body(..., description="Filter configuration as JSON"),
16080 is_shared: bool = Body(False, description="Whether query is shared with team"),
16081 user=Depends(get_current_user_with_permissions),
16082 db: Session = Depends(get_db),
16083):
16084 """Save a new observability query filter configuration.
16086 Args:
16087 request: FastAPI request object
16088 name: User-given name for the query
16089 description: Optional description
16090 filter_config: Dictionary containing all filter values
16091 is_shared: Whether this query is visible to other users
16092 user: Authenticated user (required by dependency)
16093 db: Database session for permission checks.
16095 Returns:
16096 dict: Created query details with id
16098 Raises:
16099 HTTPException: 400 if validation fails
16100 """
16101 db = next(get_db())
16102 try:
16103 # Get user email from authenticated user
16104 user_email = user.email if hasattr(user, "email") else "unknown"
16106 # Create new saved query
16107 query = ObservabilitySavedQuery(name=name, description=description, user_email=user_email, filter_config=filter_config, is_shared=is_shared)
16109 db.add(query)
16110 db.commit()
16111 db.refresh(query)
16113 return {"id": query.id, "name": query.name, "description": query.description, "filter_config": query.filter_config, "is_shared": query.is_shared, "created_at": query.created_at.isoformat()}
16114 except Exception as e:
16115 db.rollback()
16116 LOGGER.error(f"Failed to save query: {e}")
16117 raise HTTPException(status_code=400, detail=str(e))
16118 finally:
16119 # Ensure close() always runs even if commit() fails
16120 try:
16121 db.commit() # Commit read-only transaction to avoid implicit rollback
16122 finally:
16123 db.close()
16126@admin_router.get("/observability/queries", response_model=list)
16127@require_permission("admin.system_config", allow_admin_bypass=False)
16128async def list_observability_queries(request: Request, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): # pylint: disable=unused-argument
16129 """List saved observability queries for the current user.
16131 Returns user's own queries plus any shared queries.
16133 Args:
16134 request: FastAPI request object
16135 user: Authenticated user (required by dependency)
16136 db: Database session for permission checks.
16138 Returns:
16139 list: List of saved query dictionaries
16140 """
16141 db = next(get_db())
16142 try:
16143 user_email = user.email if hasattr(user, "email") else "unknown"
16145 # Get user's own queries + shared queries
16146 queries = (
16147 db.query(ObservabilitySavedQuery)
16148 .filter(or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True))
16149 .order_by(desc(ObservabilitySavedQuery.created_at))
16150 .all()
16151 )
16153 return [
16154 {
16155 "id": q.id,
16156 "name": q.name,
16157 "description": q.description,
16158 "filter_config": q.filter_config,
16159 "is_shared": q.is_shared,
16160 "user_email": q.user_email,
16161 "created_at": q.created_at.isoformat(),
16162 "last_used_at": q.last_used_at.isoformat() if q.last_used_at else None,
16163 "use_count": q.use_count,
16164 }
16165 for q in queries
16166 ]
16167 finally:
16168 # Ensure close() always runs even if commit() fails
16169 try:
16170 db.commit() # Commit read-only transaction to avoid implicit rollback
16171 finally:
16172 db.close()
16175@admin_router.get("/observability/queries/{query_id}", response_model=dict)
16176@require_permission("admin.system_config", allow_admin_bypass=False)
16177async def get_observability_query(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): # pylint: disable=unused-argument
16178 """Get a specific saved query by ID.
16180 Args:
16181 request: FastAPI request object
16182 query_id: ID of the saved query
16183 user: Authenticated user (required by dependency)
16184 db: Database session for permission checks.
16186 Returns:
16187 dict: Query details
16189 Raises:
16190 HTTPException: 404 if query not found or unauthorized
16191 """
16192 db = next(get_db())
16193 try:
16194 user_email = user.email if hasattr(user, "email") else "unknown"
16196 # Can only access own queries or shared queries
16197 query = (
16198 db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True)).first()
16199 )
16201 if not query:
16202 raise HTTPException(status_code=404, detail="Query not found or unauthorized")
16204 return {
16205 "id": query.id,
16206 "name": query.name,
16207 "description": query.description,
16208 "filter_config": query.filter_config,
16209 "is_shared": query.is_shared,
16210 "user_email": query.user_email,
16211 "created_at": query.created_at.isoformat(),
16212 "last_used_at": query.last_used_at.isoformat() if query.last_used_at else None,
16213 "use_count": query.use_count,
16214 }
16215 finally:
16216 # Ensure close() always runs even if commit() fails
16217 try:
16218 db.commit() # Commit read-only transaction to avoid implicit rollback
16219 finally:
16220 db.close()
16223@admin_router.put("/observability/queries/{query_id}", response_model=dict)
16224@require_permission("admin.system_config", allow_admin_bypass=False)
16225async def update_observability_query(
16226 request: Request, # pylint: disable=unused-argument
16227 query_id: int,
16228 name: Optional[str] = Body(None),
16229 description: Optional[str] = Body(None),
16230 filter_config: Optional[dict] = Body(None),
16231 is_shared: Optional[bool] = Body(None),
16232 user=Depends(get_current_user_with_permissions),
16233 db: Session = Depends(get_db),
16234):
16235 """Update an existing saved query.
16237 Args:
16238 request: FastAPI request object
16239 query_id: ID of the query to update
16240 name: New name (optional)
16241 description: New description (optional)
16242 filter_config: New filter configuration (optional)
16243 is_shared: New sharing status (optional)
16244 user: Authenticated user (required by dependency)
16245 db: Database session for permission checks.
16247 Returns:
16248 dict: Updated query details
16250 Raises:
16251 HTTPException: 404 if query not found, 403 if unauthorized
16252 """
16253 db = next(get_db())
16254 try:
16255 user_email = user.email if hasattr(user, "email") else "unknown"
16257 # Can only update own queries
16258 query = db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, ObservabilitySavedQuery.user_email == user_email).first()
16260 if not query:
16261 raise HTTPException(status_code=404, detail="Query not found or unauthorized")
16263 # Update fields if provided
16264 if name is not None:
16265 query.name = name
16266 if description is not None:
16267 query.description = description
16268 if filter_config is not None:
16269 query.filter_config = filter_config
16270 if is_shared is not None:
16271 query.is_shared = is_shared
16273 db.commit()
16274 db.refresh(query)
16276 return {
16277 "id": query.id,
16278 "name": query.name,
16279 "description": query.description,
16280 "filter_config": query.filter_config,
16281 "is_shared": query.is_shared,
16282 "updated_at": query.updated_at.isoformat(),
16283 }
16284 except HTTPException:
16285 raise
16286 except Exception as e:
16287 db.rollback()
16288 LOGGER.error(f"Failed to update query: {e}")
16289 raise HTTPException(status_code=400, detail=str(e))
16290 finally:
16291 # Ensure close() always runs even if commit() fails
16292 try:
16293 db.commit() # Commit read-only transaction to avoid implicit rollback
16294 finally:
16295 db.close()
16298@admin_router.delete("/observability/queries/{query_id}", status_code=204)
16299@require_permission("admin.system_config", allow_admin_bypass=False)
16300async def delete_observability_query(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): # pylint: disable=unused-argument
16301 """Delete a saved query.
16303 Args:
16304 request: FastAPI request object
16305 query_id: ID of the query to delete
16306 user: Authenticated user (required by dependency)
16307 db: Database session for permission checks.
16309 Raises:
16310 HTTPException: 404 if query not found, 403 if unauthorized
16311 """
16312 db = next(get_db())
16313 try:
16314 user_email = user.email if hasattr(user, "email") else "unknown"
16316 # Can only delete own queries
16317 query = db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, ObservabilitySavedQuery.user_email == user_email).first()
16319 if not query:
16320 raise HTTPException(status_code=404, detail="Query not found or unauthorized")
16322 db.delete(query)
16323 db.commit()
16324 finally:
16325 # Ensure close() always runs even if commit() fails
16326 try:
16327 db.commit() # Commit read-only transaction to avoid implicit rollback
16328 finally:
16329 db.close()
16332@admin_router.post("/observability/queries/{query_id}/use", response_model=dict)
16333@require_permission("admin.system_config", allow_admin_bypass=False)
16334async def track_query_usage(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): # pylint: disable=unused-argument
16335 """Track usage of a saved query (increments use count and updates last_used_at).
16337 Args:
16338 request: FastAPI request object
16339 query_id: ID of the query being used
16340 user: Authenticated user (required by dependency)
16341 db: Database session for permission checks.
16343 Returns:
16344 dict: Updated query usage stats
16346 Raises:
16347 HTTPException: 404 if query not found or unauthorized
16348 """
16349 db = next(get_db())
16350 try:
16351 user_email = user.email if hasattr(user, "email") else "unknown"
16353 # Can track usage for own queries or shared queries
16354 query = (
16355 db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True)).first()
16356 )
16358 if not query:
16359 raise HTTPException(status_code=404, detail="Query not found or unauthorized")
16361 # Update usage tracking
16362 query.use_count += 1
16363 query.last_used_at = utc_now()
16365 db.commit()
16366 db.refresh(query)
16368 return {"use_count": query.use_count, "last_used_at": query.last_used_at.isoformat()}
16369 except HTTPException:
16370 raise
16371 except Exception as e:
16372 db.rollback()
16373 LOGGER.error(f"Failed to track query usage: {e}")
16374 raise HTTPException(status_code=400, detail=str(e))
16375 finally:
16376 # Ensure close() always runs even if commit() fails
16377 try:
16378 db.commit() # Commit read-only transaction to avoid implicit rollback
16379 finally:
16380 db.close()
16383@admin_router.get("/observability/metrics/percentiles", response_model=dict)
16384@require_permission("admin.system_config", allow_admin_bypass=False)
16385async def get_latency_percentiles(
16386 request: Request, # pylint: disable=unused-argument
16387 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
16388 interval_minutes: int = Query(60, ge=5, le=1440, description="Aggregation interval in minutes"),
16389 _user=Depends(get_current_user_with_permissions),
16390 db: Session = Depends(get_db),
16391):
16392 """Get latency percentiles (p50, p90, p95, p99) over time.
16394 Args:
16395 request: FastAPI request object
16396 hours: Number of hours to look back (1-168)
16397 interval_minutes: Aggregation interval in minutes (5-1440)
16398 _user: Authenticated user (required by dependency)
16399 db: Database session for permission checks.
16401 Returns:
16402 dict: Time-series data with percentiles
16404 Raises:
16405 HTTPException: 500 if calculation fails
16406 """
16407 db = next(get_db())
16408 try:
16409 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
16411 # Use SQL aggregation for PostgreSQL, Python fallback for SQLite
16412 dialect_name = db.get_bind().dialect.name
16413 if dialect_name == "postgresql":
16414 return _get_latency_percentiles_postgresql(db, cutoff_time, interval_minutes)
16415 return _get_latency_percentiles_python(db, cutoff_time, interval_minutes)
16416 except Exception as e:
16417 LOGGER.error(f"Failed to calculate latency percentiles: {e}")
16418 raise HTTPException(status_code=500, detail=str(e))
16419 finally:
16420 # Ensure close() always runs even if commit() fails
16421 try:
16422 db.commit() # Commit read-only transaction to avoid implicit rollback
16423 finally:
16424 db.close()
16427def _get_latency_percentiles_postgresql(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict:
16428 """Compute time-bucketed latency percentiles using PostgreSQL.
16430 Args:
16431 db: Database session
16432 cutoff_time: Start time for analysis
16433 interval_minutes: Bucket size in minutes
16435 Returns:
16436 dict: Time-series percentile data
16437 """
16438 # PostgreSQL query with epoch-based bucketing (works for any interval including > 60 min)
16439 stats_sql = text(
16440 """
16441 SELECT
16442 TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM start_time) / :interval_seconds) * :interval_seconds) as bucket,
16443 percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) as p50,
16444 percentile_cont(0.90) WITHIN GROUP (ORDER BY duration_ms) as p90,
16445 percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) as p95,
16446 percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) as p99
16447 FROM observability_traces
16448 WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL
16449 GROUP BY bucket
16450 ORDER BY bucket
16451 """
16452 )
16454 interval_seconds = interval_minutes * 60
16455 results = db.execute(stats_sql, {"cutoff_time": cutoff_time, "interval_seconds": interval_seconds}).fetchall()
16457 if not results:
16458 return {"timestamps": [], "p50": [], "p90": [], "p95": [], "p99": []}
16460 timestamps = []
16461 p50_values = []
16462 p90_values = []
16463 p95_values = []
16464 p99_values = []
16466 for row in results:
16467 timestamps.append(row.bucket.isoformat() if row.bucket else "")
16468 p50_values.append(round(float(row.p50), 2) if row.p50 else 0)
16469 p90_values.append(round(float(row.p90), 2) if row.p90 else 0)
16470 p95_values.append(round(float(row.p95), 2) if row.p95 else 0)
16471 p99_values.append(round(float(row.p99), 2) if row.p99 else 0)
16473 return {"timestamps": timestamps, "p50": p50_values, "p90": p90_values, "p95": p95_values, "p99": p99_values}
16476def _get_latency_percentiles_python(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict:
16477 """Compute time-bucketed latency percentiles using Python (fallback for SQLite).
16479 Args:
16480 db: Database session
16481 cutoff_time: Start time for analysis
16482 interval_minutes: Bucket size in minutes
16484 Returns:
16485 dict: Time-series percentile data
16486 """
16487 # Query all traces with duration in time range
16488 traces = (
16489 db.query(ObservabilityTrace.start_time, ObservabilityTrace.duration_ms)
16490 .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None))
16491 .order_by(ObservabilityTrace.start_time)
16492 .all()
16493 )
16495 if not traces:
16496 return {"timestamps": [], "p50": [], "p90": [], "p95": [], "p99": []}
16498 # Group traces into time buckets using epoch-based bucketing (works for any interval)
16499 interval_seconds = interval_minutes * 60
16500 buckets: Dict[datetime, List[float]] = defaultdict(list)
16501 for trace in traces:
16502 trace_time = trace.start_time
16503 if trace_time.tzinfo is None:
16504 trace_time = trace_time.replace(tzinfo=timezone.utc)
16505 epoch = trace_time.timestamp()
16506 bucket_epoch = (epoch // interval_seconds) * interval_seconds
16507 bucket_time = datetime.fromtimestamp(bucket_epoch, tz=timezone.utc)
16508 buckets[bucket_time].append(trace.duration_ms)
16510 # Calculate percentiles for each bucket
16511 timestamps = []
16512 p50_values = []
16513 p90_values = []
16514 p95_values = []
16515 p99_values = []
16517 def percentile_cont(data: List[float], p: float) -> float:
16518 """Linear interpolation percentile matching PostgreSQL percentile_cont.
16520 Args:
16521 data: Sorted list of float values.
16522 p: Percentile value between 0 and 1.
16524 Returns:
16525 float: Interpolated percentile value.
16526 """
16527 n = len(data)
16528 k = p * (n - 1)
16529 f = int(k)
16530 c = k - f
16531 next_i = min(f + 1, n - 1)
16532 return data[f] + c * (data[next_i] - data[f])
16534 for bucket_time in sorted(buckets.keys()):
16535 durations = sorted(buckets[bucket_time])
16537 if durations:
16538 timestamps.append(bucket_time.isoformat())
16539 p50_values.append(round(percentile_cont(durations, 0.50), 2))
16540 p90_values.append(round(percentile_cont(durations, 0.90), 2))
16541 p95_values.append(round(percentile_cont(durations, 0.95), 2))
16542 p99_values.append(round(percentile_cont(durations, 0.99), 2))
16544 return {"timestamps": timestamps, "p50": p50_values, "p90": p90_values, "p95": p95_values, "p99": p99_values}
16547@admin_router.get("/observability/metrics/timeseries", response_model=dict)
16548@require_permission("admin.system_config", allow_admin_bypass=False)
16549async def get_timeseries_metrics(
16550 request: Request, # pylint: disable=unused-argument
16551 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
16552 interval_minutes: int = Query(60, ge=5, le=1440, description="Aggregation interval in minutes"),
16553 _user=Depends(get_current_user_with_permissions),
16554 db: Session = Depends(get_db),
16555):
16556 """Get time-series metrics (request rate, error rate, throughput).
16558 Args:
16559 request: FastAPI request object
16560 hours: Number of hours to look back (1-168)
16561 interval_minutes: Aggregation interval in minutes (5-1440)
16562 _user: Authenticated user (required by dependency)
16563 db: Database session for permission checks.
16565 Returns:
16566 dict: Time-series data with request counts, error rates, and throughput
16568 Raises:
16569 HTTPException: 500 if calculation fails
16570 """
16571 db = next(get_db())
16572 try:
16573 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
16575 # Use SQL aggregation for PostgreSQL, Python fallback for SQLite
16576 dialect_name = db.get_bind().dialect.name
16577 if dialect_name == "postgresql":
16578 return _get_timeseries_metrics_postgresql(db, cutoff_time, interval_minutes)
16579 return _get_timeseries_metrics_python(db, cutoff_time, interval_minutes)
16580 except Exception as e:
16581 LOGGER.error(f"Failed to calculate timeseries metrics: {e}")
16582 raise HTTPException(status_code=500, detail=str(e))
16583 finally:
16584 # Ensure close() always runs even if commit() fails
16585 try:
16586 db.commit() # Commit read-only transaction to avoid implicit rollback
16587 finally:
16588 db.close()
16591def _get_timeseries_metrics_postgresql(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict:
16592 """Compute time-series metrics using PostgreSQL.
16594 Args:
16595 db: Database session
16596 cutoff_time: Start time for analysis
16597 interval_minutes: Bucket size in minutes
16599 Returns:
16600 dict: Time-series metrics data
16601 """
16602 # Use epoch-based bucketing (works for any interval including > 60 min)
16603 stats_sql = text(
16604 """
16605 SELECT
16606 TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM start_time) / :interval_seconds) * :interval_seconds) as bucket,
16607 COUNT(*) as total,
16608 SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) as success,
16609 SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error
16610 FROM observability_traces
16611 WHERE start_time >= :cutoff_time
16612 GROUP BY bucket
16613 ORDER BY bucket
16614 """
16615 )
16617 interval_seconds = interval_minutes * 60
16618 results = db.execute(stats_sql, {"cutoff_time": cutoff_time, "interval_seconds": interval_seconds}).fetchall()
16620 if not results:
16621 return {"timestamps": [], "request_count": [], "success_count": [], "error_count": [], "error_rate": []}
16623 timestamps = []
16624 request_counts = []
16625 success_counts = []
16626 error_counts = []
16627 error_rates = []
16629 for row in results:
16630 total = row.total or 0
16631 error = row.error or 0
16632 error_rate = (error / total * 100) if total > 0 else 0
16634 timestamps.append(row.bucket.isoformat() if row.bucket else "")
16635 request_counts.append(total)
16636 success_counts.append(row.success or 0)
16637 error_counts.append(error)
16638 error_rates.append(round(error_rate, 2))
16640 return {
16641 "timestamps": timestamps,
16642 "request_count": request_counts,
16643 "success_count": success_counts,
16644 "error_count": error_counts,
16645 "error_rate": error_rates,
16646 }
16649def _get_timeseries_metrics_python(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict:
16650 """Compute time-series metrics using Python (fallback for SQLite).
16652 Args:
16653 db: Database session
16654 cutoff_time: Start time for analysis
16655 interval_minutes: Bucket size in minutes
16657 Returns:
16658 dict: Time-series metrics data
16659 """
16660 # Query traces grouped by time bucket
16661 traces = db.query(ObservabilityTrace.start_time, ObservabilityTrace.status).filter(ObservabilityTrace.start_time >= cutoff_time).order_by(ObservabilityTrace.start_time).all()
16663 if not traces:
16664 return {"timestamps": [], "request_count": [], "success_count": [], "error_count": [], "error_rate": []}
16666 # Group traces into time buckets using epoch-based bucketing (works for any interval)
16667 interval_seconds = interval_minutes * 60
16668 buckets: Dict[datetime, Dict[str, int]] = defaultdict(lambda: {"total": 0, "success": 0, "error": 0})
16669 for trace in traces:
16670 trace_time = trace.start_time
16671 if trace_time.tzinfo is None:
16672 trace_time = trace_time.replace(tzinfo=timezone.utc)
16673 epoch = trace_time.timestamp()
16674 bucket_epoch = (epoch // interval_seconds) * interval_seconds
16675 bucket_time = datetime.fromtimestamp(bucket_epoch, tz=timezone.utc)
16677 buckets[bucket_time]["total"] += 1
16678 if trace.status == "ok":
16679 buckets[bucket_time]["success"] += 1
16680 elif trace.status == "error":
16681 buckets[bucket_time]["error"] += 1
16683 # Build time-series arrays
16684 timestamps = []
16685 request_counts = []
16686 success_counts = []
16687 error_counts = []
16688 error_rates = []
16690 for bucket_time in sorted(buckets.keys()):
16691 bucket = buckets[bucket_time]
16692 error_rate = (bucket["error"] / bucket["total"] * 100) if bucket["total"] > 0 else 0
16694 timestamps.append(bucket_time.isoformat())
16695 request_counts.append(bucket["total"])
16696 success_counts.append(bucket["success"])
16697 error_counts.append(bucket["error"])
16698 error_rates.append(round(error_rate, 2))
16700 return {
16701 "timestamps": timestamps,
16702 "request_count": request_counts,
16703 "success_count": success_counts,
16704 "error_count": error_counts,
16705 "error_rate": error_rates,
16706 }
16709def _get_latency_heatmap_postgresql(db: Session, cutoff_time: datetime, hours: int, time_buckets: int, latency_buckets: int) -> dict:
16710 """Compute latency heatmap using PostgreSQL (optimized path).
16712 Uses SQL arithmetic for efficient 2D histogram computation.
16714 Args:
16715 db: Database session
16716 cutoff_time: Start time for analysis
16717 hours: Time range in hours
16718 time_buckets: Number of time buckets
16719 latency_buckets: Number of latency buckets
16721 Returns:
16722 dict: Heatmap data with time and latency dimensions
16723 """
16724 # First, get min/max durations
16725 stats_query = text(
16726 """
16727 SELECT MIN(duration_ms) as min_d, MAX(duration_ms) as max_d
16728 FROM observability_traces
16729 WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL
16730 """
16731 )
16732 stats_row = db.execute(stats_query, {"cutoff_time": cutoff_time}).fetchone()
16734 if not stats_row or stats_row.min_d is None:
16735 return {"time_labels": [], "latency_labels": [], "data": []}
16737 min_duration = float(stats_row.min_d)
16738 max_duration = float(stats_row.max_d)
16739 latency_range = max_duration - min_duration
16741 # Handle case where all durations are the same
16742 if latency_range == 0:
16743 latency_range = 1.0
16745 time_range_minutes = hours * 60
16746 latency_bucket_size = latency_range / latency_buckets
16747 time_bucket_minutes = time_range_minutes / time_buckets
16749 # Use SQL arithmetic for 2D histogram bucketing
16750 heatmap_query = text(
16751 """
16752 SELECT
16753 LEAST(GREATEST(
16754 (EXTRACT(EPOCH FROM (start_time - :cutoff_time)) / 60.0 / :time_bucket_minutes)::int,
16755 0
16756 ), :time_buckets - 1) as time_idx,
16757 LEAST(GREATEST(
16758 ((duration_ms - :min_duration) / :latency_bucket_size)::int,
16759 0
16760 ), :latency_buckets - 1) as latency_idx,
16761 COUNT(*) as cnt
16762 FROM observability_traces
16763 WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL
16764 GROUP BY time_idx, latency_idx
16765 """
16766 )
16768 rows = db.execute(
16769 heatmap_query,
16770 {
16771 "cutoff_time": cutoff_time,
16772 "time_bucket_minutes": time_bucket_minutes,
16773 "time_buckets": time_buckets,
16774 "min_duration": min_duration,
16775 "latency_bucket_size": latency_bucket_size,
16776 "latency_buckets": latency_buckets,
16777 },
16778 ).fetchall()
16780 # Initialize heatmap matrix
16781 heatmap = [[0 for _ in range(time_buckets)] for _ in range(latency_buckets)]
16783 # Populate from SQL results
16784 for row in rows:
16785 time_idx = int(row.time_idx)
16786 latency_idx = int(row.latency_idx)
16787 if 0 <= time_idx < time_buckets and 0 <= latency_idx < latency_buckets:
16788 heatmap[latency_idx][time_idx] = int(row.cnt)
16790 # Generate labels
16791 time_labels = []
16792 for i in range(time_buckets):
16793 bucket_time = cutoff_time + timedelta(minutes=i * time_bucket_minutes)
16794 time_labels.append(bucket_time.strftime("%H:%M"))
16796 latency_labels = []
16797 for i in range(latency_buckets):
16798 bucket_min = min_duration + i * latency_bucket_size
16799 bucket_max = bucket_min + latency_bucket_size
16800 latency_labels.append(f"{bucket_min:.0f}-{bucket_max:.0f}ms")
16802 return {"time_labels": time_labels, "latency_labels": latency_labels, "data": heatmap}
16805def _get_latency_heatmap_python(db: Session, cutoff_time: datetime, hours: int, time_buckets: int, latency_buckets: int) -> dict:
16806 """Compute latency heatmap using Python (fallback for SQLite).
16808 Args:
16809 db: Database session
16810 cutoff_time: Start time for analysis
16811 hours: Time range in hours
16812 time_buckets: Number of time buckets
16813 latency_buckets: Number of latency buckets
16815 Returns:
16816 dict: Heatmap data with time and latency dimensions
16817 """
16818 # Query all traces with duration
16819 traces = (
16820 db.query(ObservabilityTrace.start_time, ObservabilityTrace.duration_ms)
16821 .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None))
16822 .order_by(ObservabilityTrace.start_time)
16823 .all()
16824 )
16826 if not traces:
16827 return {"time_labels": [], "latency_labels": [], "data": []}
16829 # Calculate time bucket size
16830 time_range = hours * 60 # minutes
16831 time_bucket_minutes = time_range / time_buckets
16833 # Find latency range and create buckets
16834 durations = [t.duration_ms for t in traces]
16835 min_duration = min(durations)
16836 max_duration = max(durations)
16837 latency_range = max_duration - min_duration
16838 latency_bucket_size = latency_range / latency_buckets if latency_range > 0 else 1
16840 # Initialize heatmap matrix
16841 heatmap = [[0 for _ in range(time_buckets)] for _ in range(latency_buckets)]
16843 # Populate heatmap
16844 for trace in traces:
16845 trace_time = trace.start_time
16846 # Convert naive SQLite datetime to UTC aware
16847 if trace_time.tzinfo is None:
16848 trace_time = trace_time.replace(tzinfo=timezone.utc)
16850 # Calculate time bucket index
16851 time_diff = (trace_time - cutoff_time).total_seconds() / 60 # minutes
16852 time_idx = min(int(time_diff / time_bucket_minutes), time_buckets - 1)
16854 # Calculate latency bucket index
16855 latency_idx = min(int((trace.duration_ms - min_duration) / latency_bucket_size), latency_buckets - 1)
16857 heatmap[latency_idx][time_idx] += 1
16859 # Generate labels
16860 time_labels = []
16861 for i in range(time_buckets):
16862 bucket_time = cutoff_time + timedelta(minutes=i * time_bucket_minutes)
16863 time_labels.append(bucket_time.strftime("%H:%M"))
16865 latency_labels = []
16866 for i in range(latency_buckets):
16867 bucket_min = min_duration + i * latency_bucket_size
16868 bucket_max = bucket_min + latency_bucket_size
16869 latency_labels.append(f"{bucket_min:.0f}-{bucket_max:.0f}ms")
16871 return {"time_labels": time_labels, "latency_labels": latency_labels, "data": heatmap}
16874@admin_router.get("/observability/metrics/top-slow", response_model=dict)
16875@require_permission("admin.system_config", allow_admin_bypass=False)
16876async def get_top_slow_endpoints(
16877 request: Request, # pylint: disable=unused-argument
16878 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
16879 limit: int = Query(10, ge=1, le=100, description="Number of results"),
16880 _user=Depends(get_current_user_with_permissions),
16881 db: Session = Depends(get_db),
16882):
16883 """Get top N slowest endpoints by average duration.
16885 Args:
16886 request: FastAPI request object
16887 hours: Number of hours to look back (1-168)
16888 limit: Number of results to return (1-100)
16889 _user: Authenticated user (required by dependency)
16890 db: Database session for permission checks.
16892 Returns:
16893 dict: List of slowest endpoints with stats
16895 Raises:
16896 HTTPException: 500 if query fails
16897 """
16898 db = next(get_db())
16899 try:
16900 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
16902 # Group by endpoint and calculate average duration
16903 results = (
16904 db.query(
16905 ObservabilityTrace.http_url,
16906 ObservabilityTrace.http_method,
16907 func.count(ObservabilityTrace.trace_id).label("count"), # pylint: disable=not-callable
16908 func.avg(ObservabilityTrace.duration_ms).label("avg_duration"),
16909 func.max(ObservabilityTrace.duration_ms).label("max_duration"),
16910 )
16911 .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None))
16912 .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method)
16913 .order_by(desc("avg_duration"))
16914 .limit(limit)
16915 .all()
16916 )
16918 endpoints = []
16919 for row in results:
16920 endpoints.append(
16921 {
16922 "endpoint": f"{row.http_method} {row.http_url}",
16923 "method": row.http_method,
16924 "url": row.http_url,
16925 "count": row.count,
16926 "avg_duration_ms": round(row.avg_duration, 2),
16927 "max_duration_ms": round(row.max_duration, 2),
16928 }
16929 )
16931 return {"endpoints": endpoints}
16932 except Exception as e:
16933 LOGGER.error(f"Failed to get top slow endpoints: {e}")
16934 raise HTTPException(status_code=500, detail=str(e))
16935 finally:
16936 # Ensure close() always runs even if commit() fails
16937 try:
16938 db.commit() # Commit read-only transaction to avoid implicit rollback
16939 finally:
16940 db.close()
16943@admin_router.get("/observability/metrics/top-volume", response_model=dict)
16944@require_permission("admin.system_config", allow_admin_bypass=False)
16945async def get_top_volume_endpoints(
16946 request: Request, # pylint: disable=unused-argument
16947 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
16948 limit: int = Query(10, ge=1, le=100, description="Number of results"),
16949 _user=Depends(get_current_user_with_permissions),
16950 db: Session = Depends(get_db),
16951):
16952 """Get top N highest volume endpoints by request count.
16954 Args:
16955 request: FastAPI request object
16956 hours: Number of hours to look back (1-168)
16957 limit: Number of results to return (1-100)
16958 _user: Authenticated user (required by dependency)
16959 db: Database session for permission checks.
16961 Returns:
16962 dict: List of highest volume endpoints with stats
16964 Raises:
16965 HTTPException: 500 if query fails
16966 """
16967 db = next(get_db())
16968 try:
16969 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
16971 # Group by endpoint and count requests
16972 results = (
16973 db.query(
16974 ObservabilityTrace.http_url,
16975 ObservabilityTrace.http_method,
16976 func.count(ObservabilityTrace.trace_id).label("count"), # pylint: disable=not-callable
16977 func.avg(ObservabilityTrace.duration_ms).label("avg_duration"),
16978 )
16979 .filter(ObservabilityTrace.start_time >= cutoff_time)
16980 .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method)
16981 .order_by(desc("count"))
16982 .limit(limit)
16983 .all()
16984 )
16986 endpoints = []
16987 for row in results:
16988 endpoints.append(
16989 {
16990 "endpoint": f"{row.http_method} {row.http_url}",
16991 "method": row.http_method,
16992 "url": row.http_url,
16993 "count": row.count,
16994 "avg_duration_ms": round(row.avg_duration, 2) if row.avg_duration else 0,
16995 }
16996 )
16998 return {"endpoints": endpoints}
16999 except Exception as e:
17000 LOGGER.error(f"Failed to get top volume endpoints: {e}")
17001 raise HTTPException(status_code=500, detail=str(e))
17002 finally:
17003 # Ensure close() always runs even if commit() fails
17004 try:
17005 db.commit() # Commit read-only transaction to avoid implicit rollback
17006 finally:
17007 db.close()
17010@admin_router.get("/observability/metrics/top-errors", response_model=dict)
17011@require_permission("admin.system_config", allow_admin_bypass=False)
17012async def get_top_error_endpoints(
17013 request: Request, # pylint: disable=unused-argument
17014 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17015 limit: int = Query(10, ge=1, le=100, description="Number of results"),
17016 _user=Depends(get_current_user_with_permissions),
17017 db: Session = Depends(get_db),
17018):
17019 """Get top N error-prone endpoints by error count and rate.
17021 Args:
17022 request: FastAPI request object
17023 hours: Number of hours to look back (1-168)
17024 limit: Number of results to return (1-100)
17025 _user: Authenticated user (required by dependency)
17026 db: Database session for permission checks.
17028 Returns:
17029 dict: List of error-prone endpoints with stats
17031 Raises:
17032 HTTPException: 500 if query fails
17033 """
17034 db = next(get_db())
17035 try:
17036 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17038 # Group by endpoint and count errors
17039 results = (
17040 db.query(
17041 ObservabilityTrace.http_url,
17042 ObservabilityTrace.http_method,
17043 func.count(ObservabilityTrace.trace_id).label("total_count"), # pylint: disable=not-callable
17044 func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)).label("error_count"),
17045 )
17046 .filter(ObservabilityTrace.start_time >= cutoff_time)
17047 .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method)
17048 .having(func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)) > 0)
17049 .order_by(desc("error_count"))
17050 .limit(limit)
17051 .all()
17052 )
17054 endpoints = []
17055 for row in results:
17056 error_rate = (row.error_count / row.total_count * 100) if row.total_count > 0 else 0
17057 endpoints.append(
17058 {
17059 "endpoint": f"{row.http_method} {row.http_url}",
17060 "method": row.http_method,
17061 "url": row.http_url,
17062 "total_count": row.total_count,
17063 "error_count": row.error_count,
17064 "error_rate": round(error_rate, 2),
17065 }
17066 )
17068 return {"endpoints": endpoints}
17069 except Exception as e:
17070 LOGGER.error(f"Failed to get top error endpoints: {e}")
17071 raise HTTPException(status_code=500, detail=str(e))
17072 finally:
17073 # Ensure close() always runs even if commit() fails
17074 try:
17075 db.commit() # Commit read-only transaction to avoid implicit rollback
17076 finally:
17077 db.close()
17080@admin_router.get("/observability/metrics/heatmap", response_model=dict)
17081@require_permission("admin.system_config", allow_admin_bypass=False)
17082async def get_latency_heatmap(
17083 request: Request, # pylint: disable=unused-argument
17084 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17085 time_buckets: int = Query(24, ge=10, le=100, description="Number of time buckets"),
17086 latency_buckets: int = Query(20, ge=5, le=50, description="Number of latency buckets"),
17087 _user=Depends(get_current_user_with_permissions),
17088 db: Session = Depends(get_db),
17089):
17090 """Get latency distribution heatmap data.
17092 Uses PostgreSQL SQL aggregation for efficient computation when available,
17093 falls back to Python for SQLite.
17095 Args:
17096 request: FastAPI request object
17097 hours: Number of hours to look back (1-168)
17098 time_buckets: Number of time buckets (10-100)
17099 latency_buckets: Number of latency buckets (5-50)
17100 _user: Authenticated user (required by dependency)
17101 db: Database session for permission checks.
17103 Returns:
17104 dict: Heatmap data with time and latency dimensions
17106 Raises:
17107 HTTPException: 500 if calculation fails
17108 """
17109 db = next(get_db())
17110 try:
17111 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17113 # Route to appropriate implementation based on database dialect
17114 dialect_name = db.get_bind().dialect.name
17115 if dialect_name == "postgresql":
17116 return _get_latency_heatmap_postgresql(db, cutoff_time, hours, time_buckets, latency_buckets)
17117 return _get_latency_heatmap_python(db, cutoff_time, hours, time_buckets, latency_buckets)
17118 except Exception as e:
17119 LOGGER.error(f"Failed to generate latency heatmap: {e}")
17120 raise HTTPException(status_code=500, detail=str(e))
17121 finally:
17122 # Ensure close() always runs even if commit() fails
17123 try:
17124 db.commit() # Commit read-only transaction to avoid implicit rollback
17125 finally:
17126 db.close()
17129@admin_router.get("/observability/tools/usage", response_model=dict)
17130@require_permission("admin.system_config", allow_admin_bypass=False)
17131async def get_tool_usage(
17132 request: Request, # pylint: disable=unused-argument
17133 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17134 limit: int = Query(20, ge=5, le=100, description="Number of tools to return"),
17135 _user=Depends(get_current_user_with_permissions),
17136 db: Session = Depends(get_db),
17137):
17138 """Get tool usage frequency statistics.
17140 Args:
17141 request: FastAPI request object
17142 hours: Number of hours to look back (1-168)
17143 limit: Maximum number of tools to return (5-100)
17144 _user: Authenticated user (required by dependency)
17145 db: Database session for permission checks.
17147 Returns:
17148 dict: Tool usage statistics with counts and percentages
17150 Raises:
17151 HTTPException: 500 if calculation fails
17152 """
17153 db = next(get_db())
17154 try:
17155 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17156 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17157 dialect_name = db.get_bind().dialect.name
17159 # Query tool invocations from spans
17160 # Note: Using $."tool.name" because the JSON key contains a dot
17161 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
17162 tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name)
17163 tool_usage = (
17164 db.query(
17165 tool_name_expr.label("tool_name"),
17166 func.count(ObservabilitySpan.span_id).label("count"), # pylint: disable=not-callable
17167 )
17168 .filter(
17169 ObservabilitySpan.name == "tool.invoke",
17170 ObservabilitySpan.start_time >= cutoff_time_naive,
17171 tool_name_expr.isnot(None),
17172 )
17173 .group_by(tool_name_expr)
17174 .order_by(func.count(ObservabilitySpan.span_id).desc()) # pylint: disable=not-callable
17175 .limit(limit)
17176 .all()
17177 )
17179 total_invocations = sum(row.count for row in tool_usage)
17181 tools = [
17182 {
17183 "tool_name": row.tool_name,
17184 "count": row.count,
17185 "percentage": round((row.count / total_invocations * 100) if total_invocations > 0 else 0, 2),
17186 }
17187 for row in tool_usage
17188 ]
17190 return {"tools": tools, "total_invocations": total_invocations, "time_range_hours": hours}
17191 except Exception as e:
17192 LOGGER.error(f"Failed to get tool usage statistics: {e}")
17193 raise HTTPException(status_code=500, detail=str(e))
17194 finally:
17195 # Ensure close() always runs even if commit() fails
17196 try:
17197 db.commit() # Commit read-only transaction to avoid implicit rollback
17198 finally:
17199 db.close()
17202@admin_router.get("/observability/tools/performance", response_model=dict)
17203@require_permission("admin.system_config", allow_admin_bypass=False)
17204async def get_tool_performance(
17205 request: Request, # pylint: disable=unused-argument
17206 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17207 limit: int = Query(20, ge=5, le=100, description="Number of tools to return"),
17208 _user=Depends(get_current_user_with_permissions),
17209 db: Session = Depends(get_db),
17210):
17211 """Get tool performance metrics (avg, min, max duration).
17213 Args:
17214 request: FastAPI request object
17215 hours: Number of hours to look back (1-168)
17216 limit: Maximum number of tools to return (5-100)
17217 _user: Authenticated user (required by dependency)
17218 db: Database session for permission checks.
17220 Returns:
17221 dict: Tool performance metrics
17223 Raises:
17224 HTTPException: 500 if calculation fails
17225 """
17226 db = next(get_db())
17227 try:
17228 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17229 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17231 # Use shared helper to compute performance grouped by the JSON attribute
17232 tools = _get_span_entity_performance(
17233 db=db,
17234 cutoff_time=cutoff_time,
17235 cutoff_time_naive=cutoff_time_naive,
17236 span_names=["tool.invoke"],
17237 json_key="tool.name",
17238 result_key="tool_name",
17239 limit=limit,
17240 )
17242 return {"tools": tools, "time_range_hours": hours}
17243 except Exception as e:
17244 LOGGER.error(f"Failed to get tool performance metrics: {e}")
17245 raise HTTPException(status_code=500, detail=str(e))
17246 finally:
17247 # Ensure close() always runs even if commit() fails
17248 try:
17249 db.commit() # Commit read-only transaction to avoid implicit rollback
17250 finally:
17251 db.close()
17254@admin_router.get("/observability/tools/errors", response_model=dict)
17255@require_permission("admin.system_config", allow_admin_bypass=False)
17256async def get_tool_errors(
17257 request: Request, # pylint: disable=unused-argument
17258 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17259 limit: int = Query(20, ge=5, le=100, description="Number of tools to return"),
17260 _user=Depends(get_current_user_with_permissions),
17261 db: Session = Depends(get_db),
17262):
17263 """Get tool error rates and statistics.
17265 Args:
17266 request: FastAPI request object
17267 hours: Number of hours to look back (1-168)
17268 limit: Maximum number of tools to return (5-100)
17269 _user: Authenticated user (required by dependency)
17270 db: Database session for permission checks.
17272 Returns:
17273 dict: Tool error statistics
17275 Raises:
17276 HTTPException: 500 if calculation fails
17277 """
17278 db = next(get_db())
17279 try:
17280 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17281 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17282 dialect_name = db.get_bind().dialect.name
17284 # Query tool error rates
17285 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
17286 tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name)
17287 tool_errors = (
17288 db.query(
17289 tool_name_expr.label("tool_name"),
17290 func.count(ObservabilitySpan.span_id).label("total_count"), # pylint: disable=not-callable
17291 func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"), # pylint: disable=not-callable
17292 )
17293 .filter(
17294 ObservabilitySpan.name == "tool.invoke",
17295 ObservabilitySpan.start_time >= cutoff_time_naive,
17296 tool_name_expr.isnot(None),
17297 )
17298 .group_by(tool_name_expr)
17299 .order_by(func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).desc()) # pylint: disable=not-callable
17300 .limit(limit)
17301 .all()
17302 )
17304 tools = [
17305 {
17306 "tool_name": row.tool_name,
17307 "total_count": row.total_count,
17308 "error_count": row.error_count or 0,
17309 "error_rate": round((row.error_count / row.total_count * 100) if row.total_count > 0 and row.error_count else 0, 2),
17310 }
17311 for row in tool_errors
17312 ]
17314 return {"tools": tools, "time_range_hours": hours}
17315 except Exception as e:
17316 LOGGER.error(f"Failed to get tool error statistics: {e}")
17317 raise HTTPException(status_code=500, detail=str(e))
17318 finally:
17319 # Ensure close() always runs even if commit() fails
17320 try:
17321 db.commit() # Commit read-only transaction to avoid implicit rollback
17322 finally:
17323 db.close()
17326@admin_router.get("/observability/tools/chains", response_model=dict)
17327@require_permission("admin.system_config", allow_admin_bypass=False)
17328async def get_tool_chains(
17329 request: Request, # pylint: disable=unused-argument
17330 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17331 limit: int = Query(20, ge=5, le=100, description="Number of chains to return"),
17332 _user=Depends(get_current_user_with_permissions),
17333 db: Session = Depends(get_db),
17334):
17335 """Get tool chain analysis (which tools are invoked together in the same trace).
17337 Args:
17338 request: FastAPI request object
17339 hours: Number of hours to look back (1-168)
17340 limit: Maximum number of chains to return (5-100)
17341 _user: Authenticated user (required by dependency)
17342 db: Database session for permission checks.
17344 Returns:
17345 dict: Tool chain statistics showing common tool sequences
17347 Raises:
17348 HTTPException: 500 if calculation fails
17349 """
17350 db = next(get_db())
17351 try:
17352 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17353 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17354 dialect_name = db.get_bind().dialect.name
17356 # Get all tool invocations grouped by trace_id
17357 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
17358 tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name)
17359 tool_spans = (
17360 db.query(
17361 ObservabilitySpan.trace_id,
17362 tool_name_expr.label("tool_name"),
17363 ObservabilitySpan.start_time,
17364 )
17365 .filter(
17366 ObservabilitySpan.name == "tool.invoke",
17367 ObservabilitySpan.start_time >= cutoff_time_naive,
17368 tool_name_expr.isnot(None),
17369 )
17370 .order_by(ObservabilitySpan.trace_id, ObservabilitySpan.start_time)
17371 .all()
17372 )
17374 # Group tools by trace and create chains
17375 trace_tools = {}
17376 for span in tool_spans:
17377 if span.trace_id not in trace_tools:
17378 trace_tools[span.trace_id] = []
17379 trace_tools[span.trace_id].append(span.tool_name)
17381 # Count tool chain frequencies
17382 chain_counts = {}
17383 for tools in trace_tools.values():
17384 if len(tools) > 1:
17385 # Create a chain string (sorted to treat [A,B] and [B,A] as same chain)
17386 chain = " -> ".join(tools)
17387 chain_counts[chain] = chain_counts.get(chain, 0) + 1
17389 # Sort by frequency and take top N
17390 sorted_chains = sorted(chain_counts.items(), key=lambda x: x[1], reverse=True)[:limit]
17392 chains = [{"chain": chain, "count": count} for chain, count in sorted_chains]
17394 return {"chains": chains, "total_traces_with_tools": len(trace_tools), "time_range_hours": hours}
17395 except Exception as e:
17396 LOGGER.error(f"Failed to get tool chain statistics: {e}")
17397 raise HTTPException(status_code=500, detail=str(e))
17398 finally:
17399 # Ensure close() always runs even if commit() fails
17400 try:
17401 db.commit() # Commit read-only transaction to avoid implicit rollback
17402 finally:
17403 db.close()
17406@admin_router.get("/observability/tools/partial", response_class=HTMLResponse)
17407@require_permission("admin.system_config", allow_admin_bypass=False)
17408async def get_tools_partial(
17409 request: Request,
17410 _user=Depends(get_current_user_with_permissions),
17411 _db: Session = Depends(get_db),
17412):
17413 """Render the tool invocation metrics dashboard HTML partial.
17415 Args:
17416 request: FastAPI request object
17417 _user: Authenticated user (required by dependency)
17418 _db: Database session for permission checks.
17420 Returns:
17421 HTMLResponse: Rendered tool metrics dashboard partial
17422 """
17423 root_path = request.scope.get("root_path", "")
17424 return request.app.state.templates.TemplateResponse(
17425 request,
17426 "observability_tools.html",
17427 {
17428 "request": request,
17429 "root_path": root_path,
17430 },
17431 )
17434# ==============================================================================
17435# Prompts Observability Endpoints
17436# ==============================================================================
17439@admin_router.get("/observability/prompts/usage", response_model=dict)
17440@require_permission("admin.system_config", allow_admin_bypass=False)
17441async def get_prompt_usage(
17442 request: Request, # pylint: disable=unused-argument
17443 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17444 limit: int = Query(20, ge=5, le=100, description="Number of prompts to return"),
17445 _user=Depends(get_current_user_with_permissions),
17446 db: Session = Depends(get_db),
17447):
17448 """Get prompt rendering frequency statistics.
17450 Args:
17451 request: FastAPI request object
17452 hours: Number of hours to look back (1-168)
17453 limit: Maximum number of prompts to return (5-100)
17454 _user: Authenticated user (required by dependency)
17455 db: Database session for permission checks.
17457 Returns:
17458 dict: Prompt usage statistics with counts and percentages
17460 Raises:
17461 HTTPException: 500 if calculation fails
17462 """
17463 db = next(get_db())
17464 try:
17465 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17466 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17467 dialect_name = db.get_bind().dialect.name
17469 # Query prompt renders from spans (looking for prompts/get calls)
17470 # The prompt id should be in attributes as "prompt.id"
17471 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
17472 prompt_id_expr = extract_json_field(ObservabilitySpan.attributes, '$."prompt.id"', dialect_name=dialect_name)
17473 prompt_usage = (
17474 db.query(
17475 prompt_id_expr.label("prompt_id"),
17476 func.count(ObservabilitySpan.span_id).label("count"), # pylint: disable=not-callable
17477 )
17478 .filter(
17479 ObservabilitySpan.name.in_(["prompt.get", "prompts.get", "prompt.render"]),
17480 ObservabilitySpan.start_time >= cutoff_time_naive,
17481 prompt_id_expr.isnot(None),
17482 )
17483 .group_by(prompt_id_expr)
17484 .order_by(func.count(ObservabilitySpan.span_id).desc()) # pylint: disable=not-callable
17485 .limit(limit)
17486 .all()
17487 )
17489 total_renders = sum(row.count for row in prompt_usage)
17491 prompts = [
17492 {
17493 "prompt_id": row.prompt_id,
17494 "count": row.count,
17495 "percentage": round((row.count / total_renders * 100) if total_renders > 0 else 0, 2),
17496 }
17497 for row in prompt_usage
17498 ]
17500 return {"prompts": prompts, "total_renders": total_renders, "time_range_hours": hours}
17501 except Exception as e:
17502 LOGGER.error(f"Failed to get prompt usage statistics: {e}")
17503 raise HTTPException(status_code=500, detail=str(e))
17504 finally:
17505 # Ensure close() always runs even if commit() fails
17506 try:
17507 db.commit() # Commit read-only transaction to avoid implicit rollback
17508 finally:
17509 db.close()
17512@admin_router.get("/observability/prompts/performance", response_model=dict)
17513@require_permission("admin.system_config", allow_admin_bypass=False)
17514async def get_prompt_performance(
17515 request: Request, # pylint: disable=unused-argument
17516 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17517 limit: int = Query(20, ge=5, le=100, description="Number of prompts to return"),
17518 _user=Depends(get_current_user_with_permissions),
17519 db: Session = Depends(get_db),
17520):
17521 """Get prompt performance metrics (avg, min, max duration).
17523 Args:
17524 request: FastAPI request object
17525 hours: Number of hours to look back (1-168)
17526 limit: Maximum number of prompts to return (5-100)
17527 _user: Authenticated user (required by dependency)
17528 db: Database session for permission checks.
17530 Returns:
17531 dict: Prompt performance metrics
17533 Raises:
17534 HTTPException: 500 if calculation fails
17535 """
17536 db = next(get_db())
17537 try:
17538 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17539 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17541 # Use shared helper to compute performance grouped by the JSON attribute
17542 prompts = _get_span_entity_performance(
17543 db=db,
17544 cutoff_time=cutoff_time,
17545 cutoff_time_naive=cutoff_time_naive,
17546 span_names=["prompt.get", "prompts.get", "prompt.render"],
17547 json_key="prompt.id",
17548 result_key="prompt_id",
17549 limit=limit,
17550 )
17552 return {"prompts": prompts, "time_range_hours": hours}
17553 except Exception as e:
17554 LOGGER.error(f"Failed to get prompt performance metrics: {e}")
17555 raise HTTPException(status_code=500, detail=str(e))
17556 finally:
17557 # Ensure close() always runs even if commit() fails
17558 try:
17559 db.commit() # Commit read-only transaction to avoid implicit rollback
17560 finally:
17561 db.close()
17564@admin_router.get("/observability/prompts/errors", response_model=dict)
17565@require_permission("admin.system_config", allow_admin_bypass=False)
17566async def get_prompts_errors(
17567 hours: int = Query(24, description="Time range in hours"),
17568 limit: int = Query(20, description="Maximum number of results"),
17569 _user=Depends(get_current_user_with_permissions),
17570 db: Session = Depends(get_db),
17571):
17572 """Get prompt error rates.
17574 Args:
17575 hours: Time range in hours to analyze
17576 limit: Maximum number of prompts to return
17577 _user: Authenticated user (required by dependency)
17578 db: Database session for permission checks.
17580 Returns:
17581 dict: Prompt error statistics
17582 """
17583 db = next(get_db())
17584 try:
17585 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17586 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17587 dialect_name = db.get_bind().dialect.name
17589 # Get all prompt spans with their status
17590 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
17591 prompt_id_expr = extract_json_field(ObservabilitySpan.attributes, '$."prompt.id"', dialect_name=dialect_name)
17592 prompt_stats = (
17593 db.query(
17594 prompt_id_expr.label("prompt_id"),
17595 func.count().label("total_count"), # pylint: disable=not-callable
17596 func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"),
17597 )
17598 .filter(
17599 ObservabilitySpan.name == "prompt.render",
17600 ObservabilitySpan.start_time >= cutoff_time_naive,
17601 prompt_id_expr.isnot(None),
17602 )
17603 .group_by(prompt_id_expr)
17604 .all()
17605 )
17607 prompts_data = []
17608 for stat in prompt_stats:
17609 total = stat.total_count
17610 errors = stat.error_count or 0
17611 error_rate = round((errors / total * 100), 2) if total > 0 else 0
17613 prompts_data.append({"prompt_id": stat.prompt_id, "total_count": total, "error_count": errors, "error_rate": error_rate})
17615 # Sort by error rate descending
17616 prompts_data.sort(key=lambda x: x["error_rate"], reverse=True)
17617 prompts_data = prompts_data[:limit]
17619 return {"prompts": prompts_data, "time_range_hours": hours}
17620 finally:
17621 # Ensure close() always runs even if commit() fails
17622 try:
17623 db.commit() # Commit read-only transaction to avoid implicit rollback
17624 finally:
17625 db.close()
17628@admin_router.get("/observability/prompts/partial", response_class=HTMLResponse)
17629@require_permission("admin.system_config", allow_admin_bypass=False)
17630async def get_prompts_partial(
17631 request: Request,
17632 _user=Depends(get_current_user_with_permissions),
17633 _db: Session = Depends(get_db),
17634):
17635 """Render the prompt rendering metrics dashboard HTML partial.
17637 Args:
17638 request: FastAPI request object
17639 _user: Authenticated user (required by dependency)
17640 _db: Database session for permission checks.
17642 Returns:
17643 HTMLResponse: Rendered prompt metrics dashboard partial
17644 """
17645 root_path = request.scope.get("root_path", "")
17646 return request.app.state.templates.TemplateResponse(
17647 request,
17648 "observability_prompts.html",
17649 {
17650 "request": request,
17651 "root_path": root_path,
17652 },
17653 )
17656# ==============================================================================
17657# Resources Observability Endpoints
17658# ==============================================================================
17661@admin_router.get("/observability/resources/usage", response_model=dict)
17662@require_permission("admin.system_config", allow_admin_bypass=False)
17663async def get_resource_usage(
17664 request: Request, # pylint: disable=unused-argument
17665 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17666 limit: int = Query(20, ge=5, le=100, description="Number of resources to return"),
17667 _user=Depends(get_current_user_with_permissions),
17668 db: Session = Depends(get_db),
17669):
17670 """Get resource fetch frequency statistics.
17672 Args:
17673 request: FastAPI request object
17674 hours: Number of hours to look back (1-168)
17675 limit: Maximum number of resources to return (5-100)
17676 _user: Authenticated user (required by dependency)
17677 db: Database session for permission checks.
17679 Returns:
17680 dict: Resource usage statistics with counts and percentages
17682 Raises:
17683 HTTPException: 500 if calculation fails
17684 """
17685 db = next(get_db())
17686 try:
17687 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17688 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17689 dialect_name = db.get_bind().dialect.name
17691 # Query resource reads from spans (looking for resources/read calls)
17692 # The resource URI should be in attributes
17693 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
17694 resource_uri_expr = extract_json_field(ObservabilitySpan.attributes, '$."resource.uri"', dialect_name=dialect_name)
17695 resource_usage = (
17696 db.query(
17697 resource_uri_expr.label("resource_uri"),
17698 func.count(ObservabilitySpan.span_id).label("count"), # pylint: disable=not-callable
17699 )
17700 .filter(
17701 ObservabilitySpan.name.in_(["resource.read", "resources.read", "resource.fetch"]),
17702 ObservabilitySpan.start_time >= cutoff_time_naive,
17703 resource_uri_expr.isnot(None),
17704 )
17705 .group_by(resource_uri_expr)
17706 .order_by(func.count(ObservabilitySpan.span_id).desc()) # pylint: disable=not-callable
17707 .limit(limit)
17708 .all()
17709 )
17711 total_fetches = sum(row.count for row in resource_usage)
17713 resources = [
17714 {
17715 "resource_uri": row.resource_uri,
17716 "count": row.count,
17717 "percentage": round((row.count / total_fetches * 100) if total_fetches > 0 else 0, 2),
17718 }
17719 for row in resource_usage
17720 ]
17722 return {"resources": resources, "total_fetches": total_fetches, "time_range_hours": hours}
17723 except Exception as e:
17724 LOGGER.error(f"Failed to get resource usage statistics: {e}")
17725 raise HTTPException(status_code=500, detail=str(e))
17726 finally:
17727 # Ensure close() always runs even if commit() fails
17728 try:
17729 db.commit() # Commit read-only transaction to avoid implicit rollback
17730 finally:
17731 db.close()
17734@admin_router.get("/observability/resources/performance", response_model=dict)
17735@require_permission("admin.system_config", allow_admin_bypass=False)
17736async def get_resource_performance(
17737 request: Request, # pylint: disable=unused-argument
17738 hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
17739 limit: int = Query(20, ge=5, le=100, description="Number of resources to return"),
17740 _user=Depends(get_current_user_with_permissions),
17741 db: Session = Depends(get_db),
17742):
17743 """Get resource performance metrics (avg, min, max duration).
17745 Args:
17746 request: FastAPI request object
17747 hours: Number of hours to look back (1-168)
17748 limit: Maximum number of resources to return (5-100)
17749 _user: Authenticated user (required by dependency)
17750 db: Database session for permission checks.
17752 Returns:
17753 dict: Resource performance metrics
17755 Raises:
17756 HTTPException: 500 if calculation fails
17757 """
17758 db = next(get_db())
17759 try:
17760 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17761 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17763 # Use shared helper to compute performance grouped by the JSON attribute
17764 resources = _get_span_entity_performance(
17765 db=db,
17766 cutoff_time=cutoff_time,
17767 cutoff_time_naive=cutoff_time_naive,
17768 span_names=["resource.read", "resources.read", "resource.fetch"],
17769 json_key="resource.uri",
17770 result_key="resource_uri",
17771 limit=limit,
17772 )
17774 return {"resources": resources, "time_range_hours": hours}
17775 except Exception as e:
17776 LOGGER.error(f"Failed to get resource performance metrics: {e}")
17777 raise HTTPException(status_code=500, detail=str(e))
17778 finally:
17779 # Ensure close() always runs even if commit() fails
17780 try:
17781 db.commit() # Commit read-only transaction to avoid implicit rollback
17782 finally:
17783 db.close()
17786@admin_router.get("/observability/resources/errors", response_model=dict)
17787@require_permission("admin.system_config", allow_admin_bypass=False)
17788async def get_resources_errors(
17789 hours: int = Query(24, description="Time range in hours"),
17790 limit: int = Query(20, description="Maximum number of results"),
17791 _user=Depends(get_current_user_with_permissions),
17792 db: Session = Depends(get_db),
17793):
17794 """Get resource error rates.
17796 Args:
17797 hours: Time range in hours to analyze
17798 limit: Maximum number of resources to return
17799 _user: Authenticated user (required by dependency)
17800 db: Database session for permission checks.
17802 Returns:
17803 dict: Resource error statistics
17804 """
17805 db = next(get_db())
17806 try:
17807 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
17808 cutoff_time_naive = cutoff_time.replace(tzinfo=None)
17809 dialect_name = db.get_bind().dialect.name
17811 # Get all resource spans with their status
17812 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
17813 resource_uri_expr = extract_json_field(ObservabilitySpan.attributes, '$."resource.uri"', dialect_name=dialect_name)
17814 resource_stats = (
17815 db.query(
17816 resource_uri_expr.label("resource_uri"),
17817 func.count().label("total_count"), # pylint: disable=not-callable
17818 func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"),
17819 )
17820 .filter(
17821 ObservabilitySpan.name.in_(["resource.read", "resources.read", "resource.fetch"]),
17822 ObservabilitySpan.start_time >= cutoff_time_naive,
17823 resource_uri_expr.isnot(None),
17824 )
17825 .group_by(resource_uri_expr)
17826 .all()
17827 )
17829 resources_data = []
17830 for stat in resource_stats:
17831 total = stat.total_count
17832 errors = stat.error_count or 0
17833 error_rate = round((errors / total * 100), 2) if total > 0 else 0
17835 resources_data.append({"resource_uri": stat.resource_uri, "total_count": total, "error_count": errors, "error_rate": error_rate})
17837 # Sort by error rate descending
17838 resources_data.sort(key=lambda x: x["error_rate"], reverse=True)
17839 resources_data = resources_data[:limit]
17841 return {"resources": resources_data, "time_range_hours": hours}
17842 finally:
17843 # Ensure close() always runs even if commit() fails
17844 try:
17845 db.commit() # Commit read-only transaction to avoid implicit rollback
17846 finally:
17847 db.close()
17850@admin_router.get("/observability/resources/partial", response_class=HTMLResponse)
17851@require_permission("admin.system_config", allow_admin_bypass=False)
17852async def get_resources_partial(
17853 request: Request,
17854 _user=Depends(get_current_user_with_permissions),
17855 _db: Session = Depends(get_db),
17856):
17857 """Render the resource fetch metrics dashboard HTML partial.
17859 Args:
17860 request: FastAPI request object
17861 _user: Authenticated user (required by dependency)
17862 _db: Database session for permission checks.
17864 Returns:
17865 HTMLResponse: Rendered resource metrics dashboard partial
17866 """
17867 root_path = request.scope.get("root_path", "")
17868 return request.app.state.templates.TemplateResponse(
17869 request,
17870 "observability_resources.html",
17871 {
17872 "request": request,
17873 "root_path": root_path,
17874 },
17875 )
17878# ===================================
17879# Performance Monitoring Endpoints
17880# ===================================
17883@admin_router.get("/performance/stats", response_class=HTMLResponse)
17884@require_permission("admin.system_config", allow_admin_bypass=False)
17885async def get_performance_stats(
17886 request: Request,
17887 db: Session = Depends(get_db),
17888 _user=Depends(get_current_user_with_permissions),
17889):
17890 """Get comprehensive performance metrics for the dashboard.
17892 Returns either an HTML partial for HTMX requests or JSON for API requests.
17893 Includes system metrics, request metrics, worker status, and cache stats.
17895 Args:
17896 request: FastAPI request object
17897 db: Database session dependency
17898 _user: Authenticated user (required by dependency)
17900 Returns:
17901 HTMLResponse or JSONResponse: Performance dashboard data
17903 Raises:
17904 HTTPException: 404 if performance tracking is disabled, 500 on retrieval error
17905 """
17906 if not settings.mcpgateway_performance_tracking:
17907 if request.headers.get("hx-request"):
17908 return HTMLResponse(content='<div class="text-center py-8 text-gray-500">Performance tracking is disabled. Enable with MCPGATEWAY_PERFORMANCE_TRACKING=true</div>')
17909 raise HTTPException(status_code=404, detail="Performance monitoring is disabled")
17911 try:
17912 service = get_performance_service(db)
17913 dashboard = await service.get_dashboard()
17915 # Convert to dict for template
17916 dashboard_data = dashboard.model_dump()
17918 # Format datetime fields for display
17919 if dashboard_data.get("timestamp"):
17920 dashboard_data["timestamp"] = dashboard_data["timestamp"].isoformat()
17921 if dashboard_data.get("system", {}).get("boot_time"):
17922 dashboard_data["system"]["boot_time"] = dashboard_data["system"]["boot_time"].isoformat()
17923 for worker in dashboard_data.get("workers", []):
17924 if worker.get("create_time"):
17925 worker["create_time"] = worker["create_time"].isoformat()
17927 if request.headers.get("hx-request"):
17928 root_path = request.scope.get("root_path", "")
17929 return request.app.state.templates.TemplateResponse(
17930 request,
17931 "performance_partial.html",
17932 {
17933 "request": request,
17934 "dashboard": dashboard_data,
17935 "root_path": root_path,
17936 },
17937 )
17939 return ORJSONResponse(content=dashboard_data)
17941 except Exception as e:
17942 LOGGER.error(f"Performance metrics retrieval failed: {str(e)}", exc_info=True)
17943 raise HTTPException(status_code=500, detail=f"Failed to retrieve performance metrics: {str(e)}")
17946@admin_router.get("/performance/system")
17947@require_permission("admin.system_config", allow_admin_bypass=False)
17948async def get_performance_system(
17949 db: Session = Depends(get_db),
17950 _user=Depends(get_current_user_with_permissions),
17951):
17952 """Get current system resource metrics.
17954 Args:
17955 db: Database session dependency
17956 _user: Authenticated user (required by dependency)
17958 Returns:
17959 JSONResponse: System metrics (CPU, memory, disk, network)
17961 Raises:
17962 HTTPException: 404 if performance tracking is disabled
17963 """
17964 if not settings.mcpgateway_performance_tracking:
17965 raise HTTPException(status_code=404, detail="Performance tracking is disabled")
17967 service = get_performance_service(db)
17968 metrics = service.get_system_metrics()
17969 return metrics.model_dump()
17972@admin_router.get("/performance/workers")
17973@require_permission("admin.system_config", allow_admin_bypass=False)
17974async def get_performance_workers(
17975 db: Session = Depends(get_db),
17976 _user=Depends(get_current_user_with_permissions),
17977):
17978 """Get metrics for all worker processes.
17980 Args:
17981 db: Database session dependency
17982 _user: Authenticated user (required by dependency)
17984 Returns:
17985 JSONResponse: List of worker metrics
17987 Raises:
17988 HTTPException: 404 if performance tracking is disabled
17989 """
17990 if not settings.mcpgateway_performance_tracking:
17991 raise HTTPException(status_code=404, detail="Performance tracking is disabled")
17993 service = get_performance_service(db)
17994 workers = service.get_worker_metrics()
17995 return [w.model_dump() for w in workers]
17998@admin_router.get("/performance/requests")
17999@require_permission("admin.system_config", allow_admin_bypass=False)
18000async def get_performance_requests(
18001 db: Session = Depends(get_db),
18002 _user=Depends(get_current_user_with_permissions),
18003):
18004 """Get HTTP request performance metrics.
18006 Args:
18007 db: Database session dependency
18008 _user: Authenticated user (required by dependency)
18010 Returns:
18011 JSONResponse: Request metrics from Prometheus
18013 Raises:
18014 HTTPException: 404 if performance tracking is disabled
18015 """
18016 if not settings.mcpgateway_performance_tracking:
18017 raise HTTPException(status_code=404, detail="Performance tracking is disabled")
18019 service = get_performance_service(db)
18020 metrics = service.get_request_metrics()
18021 return metrics.model_dump()
18024@admin_router.get("/performance/cache")
18025@require_permission("admin.system_config", allow_admin_bypass=False)
18026async def get_performance_cache(
18027 db: Session = Depends(get_db),
18028 _user=Depends(get_current_user_with_permissions),
18029):
18030 """Get Redis cache metrics.
18032 Args:
18033 db: Database session dependency
18034 _user: Authenticated user (required by dependency)
18036 Returns:
18037 JSONResponse: Redis cache metrics
18039 Raises:
18040 HTTPException: 404 if performance tracking is disabled
18041 """
18042 if not settings.mcpgateway_performance_tracking:
18043 raise HTTPException(status_code=404, detail="Performance tracking is disabled")
18045 service = get_performance_service(db)
18046 metrics = await service.get_cache_metrics()
18047 return metrics.model_dump()
18050@admin_router.get("/performance/history")
18051@require_permission("admin.system_config", allow_admin_bypass=False)
18052async def get_performance_history(
18053 period_type: str = Query("hourly", description="Aggregation period: hourly or daily"),
18054 hours: int = Query(24, ge=1, le=168, description="Number of hours to look back"),
18055 db: Session = Depends(get_db),
18056 _user=Depends(get_current_user_with_permissions),
18057):
18058 """Get historical performance aggregates.
18060 Args:
18061 period_type: Aggregation type (hourly, daily)
18062 hours: Hours of history to retrieve
18063 db: Database session dependency
18064 _user: Authenticated user (required by dependency)
18066 Returns:
18067 JSONResponse: Historical performance aggregates
18069 Raises:
18070 HTTPException: 404 if performance tracking is disabled
18071 """
18072 if not settings.mcpgateway_performance_tracking:
18073 raise HTTPException(status_code=404, detail="Performance tracking is disabled")
18075 service = get_performance_service(db)
18076 start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
18078 history = await service.get_history(
18079 db=db,
18080 period_type=period_type,
18081 start_time=start_time,
18082 )
18084 return history.model_dump()